You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

LazyVGrid下拉刷新后onAppear不触发,分页失效求助

问题:LazyVGrid下拉刷新后分页失效(onAppear不再触发)

我在为LazyVGrid实现分页和下拉刷新功能,初始加载时分页正常,每个单元格的onAppear都会触发;但下拉刷新后,单元格的onAppear不再调用,导致只能加载第一页内容。iPhone上用List布局时没有这个问题,推测是LazyVGrid的问题。

视图代码

struct AlbumListView: View {
    @Environment(\.horizontalSizeClass) var horizontalSize
    @StateObject var viewModel: LibraryViewModel
    @EnvironmentObject var coordinator: Coordinator
    @EnvironmentObject var database: Database
    @EnvironmentObject var accountHolder: AccountHolder
    func gridItems(width: Double) -> ([GridItem], Double) {
        let count = Int((width / 200.0).rounded())
        let item = GridItem(.flexible(), spacing: 8, alignment: .top)
        let itemWidth: Double = (width-(8*(Double(count)+1)))/Double(count)
        return (Array(repeating: item, count: count), itemWidth)
    }
    var body: some View {
        if UIDevice.current.userInterfaceIdiom == .pad {
            GeometryReader { geometry in
                ScrollView {
                    let (gridItems, width) = gridItems(width: geometry.size.width)
                    LazyVGrid(columns: gridItems, spacing: 8) {
                        ForEach(viewModel.albums) { album in
                            Button {
                                viewModel.albumTapped(albumId: album.id, coordinator: coordinator)
                            } label: {
                                AlbumGridCell(album: Album(albumListResponse: album), width: width)
                            }
                            .onAppear {
                                viewModel.albumAppeared(album: album)
                            }
                        }
                    }
                    .padding(8)
                }
                .simultaneousGesture(DragGesture().onChanged({ value in
                    withAnimation {
                        MediaControlBarMinimized.shared.isCompact = true
                    }
                }))
                .refreshable {
                    do {
                        try await viewModel.loadContent(force: true)
                    } catch {
                        print(error)
                    }
                }
                .searchable(text: $viewModel.searchText, prompt: "Search albums")
                .scrollDismissesKeyboard(.immediately)
                .navigationBarTitleDisplayMode(.inline)
                .navigationTitle(viewModel.viewType.rawValue.capitalized)
                .toolbar {
                    ToolbarTitleMenu {
                        Picker("Picker", selection: $viewModel.viewType) {
                            ForEach(LibraryViewType.allCases, id: \.self) { item in
                                Text(item.rawValue.capitalized)
                            }
                        }
                    }
                    ToolbarItem(placement: .navigationBarLeading) {
                        Button {
                            viewModel.goToLogin(coordinator: coordinator)
                        } label: {
                            Image(systemName: "person.circle").imageScale(.large)
                        }
                    }
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button {
                            viewModel.shuffle()
                        } label: {
                            Image(systemName: "shuffle").imageScale(.large)
                        }
                    }
                }
            }
        } else {
            List(viewModel.albums) { album in
                Button {
                    viewModel.albumTapped(albumId: album.id, coordinator: coordinator)
                } label: {
                    AlbumCell(album: Album(albumListResponse: album))
                }
                .listRowSeparator(.hidden)
                .onAppear {
                    viewModel.albumAppeared(album: album)
                }
            }
            .simultaneousGesture(DragGesture().onChanged({ value in
                withAnimation {
                    MediaControlBarMinimized.shared.isCompact = true
                }
            }))
            .refreshable {
                do {
                    try await viewModel.loadContent(force: true)
                } catch {
                    print(error)
                }
            }
            .listStyle(.plain)
            .searchable(text: $viewModel.searchText, prompt: "Search albums")
            .scrollDismissesKeyboard(.immediately)
            .navigationBarTitleDisplayMode(.inline)
            .navigationTitle(viewModel.viewType.rawValue.capitalized)
            .toolbar {
                ToolbarTitleMenu {
                    Picker("Picker", selection: $viewModel.viewType) {
                        ForEach(LibraryViewType.allCases, id: \.self) { item in
                            Text(item.rawValue.capitalized)
                        }
                    }
                }
                ToolbarItem(placement: .navigationBarLeading) {
                    Button {
                        viewModel.goToLogin(coordinator: coordinator)
                    } label: {
                        Image(systemName: "person.circle").imageScale(.large)
                    }
                }
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button {
                        viewModel.shuffle()
                    } label: {
                        Image(systemName: "shuffle").imageScale(.large)
                    }
                }
            }
        }
        
    }
}

ViewModel代码

class LibraryViewModel: ObservableObject {
    @Published var searchText: String
    @Published var viewType = Database.shared.libraryViewType
    var albumPage = 0
    var player = AudioManager.shared
    var database = Database.shared
    var albums: [GetAlbumListResponse.Album] {
        if searchText.isEmpty {
            return database.albumList ?? []
        } else {
            return database.albumList?.filter {
                $0.title.localizedCaseInsensitiveContains(searchText) ||
                $0.artist.localizedCaseInsensitiveContains(searchText)
            } ?? []
        }
    }
    var artists: [GetIndexesResponse.Artist] {
        if searchText.isEmpty {
            return database.artistList ?? []
        } else {
            return database.artistList?.filter {
                $0.name.localizedCaseInsensitiveContains(searchText)
            } ?? []
        }
    }
    init() {
        searchText = ""
        Task {
            do {
                try await loadContent(force: true)
            } catch {
                print(error)
            }
        }
        NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: Notification.Name("login"), object: nil)
    }
    func loadContent(force: Bool = false) async throws {
        switch viewType {
        case .albums:
            if database.albumList == nil || force {
                self.albumPage = 0
                try await getAlbumList()
            }
        case .artists:
            try await getArtists()
        }
    }
    func getAlbumList() async throws {
        let response = try await SubsonicClient.shared.getAlbumList(page: albumPage)
        DispatchQueue.main.async {
            if self.albumPage == 0 {
                self.database.albumList = response.subsonicResponse.albumList.album
            } else {
                self.database.albumList?.append(contentsOf: response.subsonicResponse.albumList.album)
            }
            self.albumPage += 1
            print("Page: \(self.albumPage), album count: \(self.albums.count)")
        }
    }
    func getArtists() async throws {
        let response = try await SubsonicClient.shared.getIndexes()
        let artists: [GetIndexesResponse.Artist] = response.subsonicResponse.indexes.index.flatMap { index in
            return index.artist
        }
        DispatchQueue.main.async {
            self.database.artistList = artists
        }
    }
    func albumAppeared(album: GetAlbumListResponse.Album) {
        if album == self.albums.last {
            Task {
                do {
                    try await self.getAlbumList()
                } catch {
                    print(error)
                }
            }
        }
    }
    func albumTapped(albumId: String, coordinator: Coordinator) {
        coordinator.albumTapped(albumId: albumId, scrollToSong: nil)
    }
    @objc func refresh() {
        Task {
            do {
                try await loadContent(force: true)
            } catch {
                print(error)
            }
        }
    }
    func shuffle() {
        Task {
            let response = try await SubsonicClient.shared.getRandomSongs()
            let songs = response.subsonicResponse.randomSongs.song.compactMap {
                return Song(randomSong: $0)
            }
            DispatchQueue.main.async {
                self.player.play(songs:songs, index: 0)
            }
        }
    }
    func goToLogin(coordinator: Coordinator) {
        coordinator.goToLogin()
    }
}

注:Database.shared是App根层级的ObservableObject,修改时视图会自动重绘,无需添加@Published。


问题原因分析

  1. LazyVGrid复用机制:下拉刷新后,database.albumList被重置为新的第一页数据,LazyVGrid可能复用之前的单元格实例,导致新数据的单元格onAppear事件不触发(尤其是新数据数量与旧数据一致或更少时)。
  2. 单元格标识冲突:如果GetAlbumListResponse.Album的id在刷新后重复,LazyVGrid会判定为同一个单元格,不会重新触发onAppear。
  3. 布局计算延迟:GeometryReader包裹的ScrollView在刷新后,可能未及时通知LazyVGrid重新计算布局,导致单元格可见性检测失效。

修复方案

方案1:强制LazyVGrid刷新时重建单元格

通过给LazyVGrid绑定一个动态id,每次刷新时更新id,强制LazyVGrid重建所有单元格:

  1. 在ViewModel中添加刷新标识:
class LibraryViewModel: ObservableObject {
    // ... 原有属性
    @Published var refreshID = UUID() // 添加该属性
    // ...
}
  1. 在loadContent方法中,当force=true时更新标识:
func loadContent(force: Bool = false) async throws {
    switch viewType {
    case .albums:
        if database.albumList == nil || force {
            self.albumPage = 0
            DispatchQueue.main.async {
                self.refreshID = UUID() // 刷新时更新标识
            }
            try await getAlbumList()
        }
    case .artists:
        try await getArtists()
    }
}
  1. 给LazyVGrid绑定id:
LazyVGrid(columns: gridItems, spacing: 8) {
    // ... 原有单元格内容
}
.id(viewModel.refreshID) // 添加该行
.padding(8)

方案2:改用滚动位置监听触发分页

放弃依赖单元格onAppear,改为监听ScrollView的滚动位置,更可靠地触发分页:

  1. 在视图中添加滚动位置绑定(需确保GetAlbumListResponse.Album实现Identifiable):
@State private var scrollPosition: GetAlbumListResponse.Album.ID? // 添加该属性

// 在ScrollView中添加监听
ScrollView {
    // ... LazyVGrid内容
}
.scrollPosition(id: $scrollPosition)
.onChange(of: scrollPosition) { _, newValue in
    guard let lastAlbumID = viewModel.albums.last?.id, newValue == lastAlbumID else { return }
    viewModel.albumAppeared(album: viewModel.albums.last!)
}

方案3:确保数据唯一性

检查API返回的GetAlbumListResponse.Album的id是否在刷新后唯一,如果存在重复,可结合分页号生成唯一标识(比如"\(album.id)-\(page)"),确保LazyVGrid能识别新单元格。


内容的提问来源于stack exchange,提问作者Halpo

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.11 18:15:11