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