iosswiftuipaginationonappearrefreshable

.onAppear is not called on my list items after I use the refresh control


I am trying to implement pagination on my lazyvgrid as well as a refresh control. initially when the page loads, the pagination works - onAppear is called for each cell. but when I pull to refresh, onAppear is not called on the cells, so only the first page is loaded:

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)
                    }
                }
            }
        }
        
    }
}

my view model:

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 is an observable object on the root of the app, so @Published is not needed here, the view re-draws when it is modified

I am guessing it is something to do with LazyVGrid since this doesn't happen when using a List (in my iPhone layout)


Solution

  • I figured out that when you refresh the items, items that are already there will not call onAppear when the data source is refreshed. to force this - you must first empty the data source, then re-populate once the data has been fetched.