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。
问题原因分析
- LazyVGrid复用机制:下拉刷新后,
database.albumList被重置为新的第一页数据,LazyVGrid可能复用之前的单元格实例,导致新数据的单元格onAppear事件不触发(尤其是新数据数量与旧数据一致或更少时)。 - 单元格标识冲突:如果
GetAlbumListResponse.Album的id在刷新后重复,LazyVGrid会判定为同一个单元格,不会重新触发onAppear。 - 布局计算延迟:
GeometryReader包裹的ScrollView在刷新后,可能未及时通知LazyVGrid重新计算布局,导致单元格可见性检测失效。
修复方案
方案1:强制LazyVGrid刷新时重建单元格
通过给LazyVGrid绑定一个动态id,每次刷新时更新id,强制LazyVGrid重建所有单元格:
- 在ViewModel中添加刷新标识:
class LibraryViewModel: ObservableObject { // ... 原有属性 @Published var refreshID = UUID() // 添加该属性 // ... }
- 在
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() } }
- 给LazyVGrid绑定
id:
LazyVGrid(columns: gridItems, spacing: 8) { // ... 原有单元格内容 } .id(viewModel.refreshID) // 添加该行 .padding(8)
方案2:改用滚动位置监听触发分页
放弃依赖单元格onAppear,改为监听ScrollView的滚动位置,更可靠地触发分页:
- 在视图中添加滚动位置绑定(需确保
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
相关产品推荐
相关产品推荐

