1

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)

Halpo
  • 2,982
  • 3
  • 25
  • 54

1 Answers1

1

Nowadays you can remove .onAppear because a proper View struct hierarchy with .task(id:) should fix these issues.

In SwiftUI, there shouldn't be view model objects, that design pattern isn't applicable because the View data struct plus @State/@Binding already does that job and it's a waste of effort trying to re-implement it with objects. There is now more than ever a reason to learn the View struct because it's the only way to use .task for async code. First, try to restructure your code into a View struct hierarchy like this:

   var body: some View {
       ...
       MySearchView(albums: database.albums)
       ...
    }

struct MySearchView: some View {
    @State var searchText: String
    let albums: [Album]

    var sortedAlbums: [Album] {
        if searchText.isEmpty {
            return albums
        } else {
            return albums.filter {
                $0.title.localizedCaseInsensitiveContains(searchText) ||
                $0.artist.localizedCaseInsensitiveContains(searchText)
            } ?? []
        }
    }

    // since body reads sortedAlbums which reads searchText and albums, SwiftUI will record that dependency and body will be called the next time any of those values are set.
    var body: View {
        ...
        MyResultsView(items: sortedAlbums) // or ForEach(sortedAlbums) if there are no other Views
        ...
    }
}

This way SwiftUI's dependency tracking works correctly, i.e. when either the let albums or the var search values change, body will be called, which results in the var sortedAlbums being recomputed and everything works correctly.

To answer your actual question, when using async/await in SwiftUI it is .task, not Task {}. .task is more powerful because it runs on appear and is cancelled on disspear, it is also cancelled and restarted when using the id param, e.g. .task(id: searchText) could start an async search of a database or web service any time the search changes whilst also supporting cancellation. Knowing this you can simply remove your onAppear and a proper View struct hierarchy with .task should fix all of your problems.

malhal
  • 26,330
  • 7
  • 115
  • 133