1

Edited from an earlier post to include a working subset of code: I'm apparently not understanding how .onAppear works in SwiftUI with respect to Views inside of Navigation Links. I'm trying to use it to get paged JSON (in this case from the Pokemon API at pokeapi.co.

A minimal reproducible bit of code is below. As I scroll through the list, I see all of the Pokemon names for the first page print out & when I hit the last Pokemon on the page, I get the next page of JSON (I can see the # jump from 20, one page, to 40, two pages). My API call seems to be working fine & I'm loading a second page of Pokemon. I see their names appear & they print to the console when running in the simulator. However, even though the JSON is properly loaded into my list & I go from 20 to 40 Pokemon - a correct array of the first two pages - as I scroll past 40 it looks like the third page has loaded, creatures through 60 are visible in the List, but the console only occasionally shows an index name printing (also shown a sample of the output, below, note the values printing past 40 don't all show). The .onAppear doesn't seem to be firing as I expected past the 40th element, even though I can see 60 names showing up in the List. I was hoping to use .onAppear to detect when a new page needs to load & call it, but this method doesn't seem sound. Any hints why .onAppear isn't working as I expect & how I might more soundly handle recognizing when I need to load the next page of JSON? Thanks!

struct Creature: Hashable, Codable {
    var name: String
    var url: String
}
 
@MainActor
class Creatures: ObservableObject {
    private struct Returned: Codable {
        var count: Int
        var next: String?
        var results: [Creature]
    }
    
    var count = 0
    var urlString = "https://pokeapi.co/api/v2/pokemon/"
    @Published var creatureArray: [Creature] = []
    var isFetching = false
    
    func getData() async {
        guard !isFetching else { return }
        isFetching = true
        
        print(" We are accessing the url \(urlString)")
        
        // Create a URL
        guard let url = URL(string: urlString) else {
            print(" ERROR: Could not create a URL from \(urlString)")
            isFetching = false
            return
        }
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            if let returned = try? JSONDecoder().decode(Returned.self, from: data) {
                self.count = returned.count
                self.urlString = returned.next ?? ""
                DispatchQueue.main.async {
                    self.creatureArray = self.creatureArray + returned.results
                }
               isFetching = false
            } else {
                isFetching = false
                print(" JSON ERROR: Could not decode returned data.")
            }
        } catch {
            isFetching = false
            print(" ERROR: Could not get URL from data at \(urlString). \(error.localizedDescription)")
        }
    }
}
 
struct ContentView: View {
    @StateObject var creatures = Creatures()
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(0..<creatures.creatureArray.count, id: \.self) { index in
                    NavigationLink {
                        Text(creatures.creatureArray[index].name)
                    } label: {
                        Text("\(index+1). \(creatures.creatureArray[index].name)")
                    }
                    .onAppear() {
                        print("index = \(index+1)")
                        if index == creatures.creatureArray.count-1 && creatures.urlString.hasPrefix("http") {
                            Task {
                                await creatures.getData()
                            }
                        }
                    }
                }
            }
            .toolbar {
                ToolbarItem (placement:.status) {
                    Text("\(creatures.creatureArray.count) of \(creatures.count)")
                }
            }
        }
        .task {
            await creatures.getData()
        }
    }
}

Here's a sample of the output. The triple dots simply indicate order printed as expected:

 We are accessing the url https://pokeapi.co/api/v2/pokemon/
index = 1
index = 2
index = 3

…
index = 37
index = 38
index = 39
index = 40
 We are accessing the url https://pokeapi.co/api/v2/pokemon/?offset=40&limit=20
index = 41
index = 44
Gallaugher
  • 1,593
  • 16
  • 27
  • There are too many missing parts to test your code (and any answers). Show a minimal example code: https://stackoverflow.com/help/minimal-reproducible-example. I assume that `creatures.shouldLoad(index: index)` is an asynchronous function. It is not a good idea to have these async functions inside the List, while the List is being build and re-build at the behest of the system. Re-structure your code to have all these (http request I assume) done before hand. – workingdog support Ukraine Oct 16 '22 at 04:30
  • you may be interested in this SO post/answer regarding retrieving Pokemons. https://stackoverflow.com/questions/73187906/stuck-with-figuring-out-how-to-use-api-response-to-make-a-call-to-retrieve-a-res It has a fully functional code in the answer. – workingdog support Ukraine Oct 16 '22 at 05:13
  • Thanks for your suggestions. I've posted a reproducible chunk of code above so you can get a sense of what I'd experienced. I don't want to fire all of the requests beforehand because I'm trying to load pages as needed as the user scrolls. If I could detect that the last element available in the list is shown, I could fire a call to load the next page. – Gallaugher Oct 16 '22 at 13:08

1 Answers1

1

Try my fully functional example code that fetches the pokemons data as required.

The code gets the server response with the results when the PokeListView first appears (in .task {...}). Then, as the user scrolls to the bottom of the current list, another page is fetched, until all data is presented.

The new page fetching is triggered by checking for the last creature id displayed and if more data is available. This is the crux of the paging. Note, you can adjust to trigger before the last creature is displayed.

As the user tap on any one of the creatures name, the details view is presented. As the PokeDetailsView appears, the details are fetched from the server or from cache. This alleviates the server burden.

The ApiService manages all server processing. With this approach you are not fetching all the details before hand, only as required.

Since you are fetching data from a remote server, there will be times when you will see the progress view, as it takes somethimes to download the data.

struct ContentView: View {
    @StateObject var apiService = ApiService()

    var body: some View {
        PokeListView()
            .environmentObject(apiService)
    }
}

struct PokeListView: View {
    @EnvironmentObject var apiService: ApiService
    
    var body: some View {
        NavigationStack {
            List(apiService.pokeList.results) { pokemon in
                NavigationLink(pokemon.name, value: pokemon.url)

                // check if need to paginate
                if let lastPoke = apiService.pokeList.results.last {
                    if pokemon.id == lastPoke.id && apiService.pokeList.next.hasPrefix("https") {
                        ProgressView()
                            .task {
                                do {
                                    try await apiService.getPokemonList()
                                } catch {
                                    print("---> refresh error: \(error)")
                                }
                            }
                    }
                }
 
            }
            .navigationDestination(for: String.self) { urlString in
                PokeDetailsView(urlString: urlString)
            }
        }
        .environmentObject(apiService)
        .task {
            do {
                try await apiService.getPokemonList()
            } catch{
                print(error)
            }
        }
    }
}

struct PokeDetailsView: View {
    @EnvironmentObject var apiService: ApiService
    @State var urlString: String
    @State var poky: Pokemon?
    
    var body: some View {
        VStack {
            Text(poky?.name ?? "no name")
            Text("height: \(poky?.height ?? 0)")
            // ... other info
        }
        .task {
            do {
                poky = try await apiService.getPokemon(from: urlString)
            } catch{
                print(error)
            }
        }
    }
}

class ApiService: ObservableObject {
    
    var serverUrl = "https://pokeapi.co/api/v2/pokemon?limit=20&offset=0"
    
    // the response from the server with the list of names and urls in `results`
    @Published var pokeList: PokemonList = PokemonList(count: 0, results: [])
    // dictionary store of Pokemons details [urlString:Pokemon]
    @Published var pokemonStore: [String : Pokemon] = [:]
    
    func getPokemonList() async throws {
        guard let url = URL(string: serverUrl) else { return }
        let (data, _) = try await URLSession.shared.data(from: url)
        Task{@MainActor in
            let morePoke = try JSONDecoder().decode(PokemonList.self, from: data)
            self.pokeList.count = morePoke.count  // <-- here
            self.pokeList.next = morePoke.next
            self.serverUrl = morePoke.next
            self.pokeList.results.append(contentsOf: morePoke.results)
        }
    }
 
    func getPokemon(from urlString: String) async throws -> Pokemon? {
        if let poky = pokemonStore[urlString] {
            // if already have it
            return poky
        } else {
            // fetch it from the server
            guard let url = URL(string: urlString) else { return nil }
            let (data, _) = try await URLSession.shared.data(from: url)
            do {
                let poky = try JSONDecoder().decode(Pokemon.self, from: data)
                Task{@MainActor in
                    // store it for later use
                    pokemonStore[urlString] = poky
                }
                return poky
            } catch {
                return nil
            }
        }
    }
    
}

// MARK: - PokemonList
struct PokemonList: Codable {
    var count: Int  // <-- here 
    var next: String
    var results: [ListItem]  // <-- don't use the word Result
    
    init(count: Int, results: [ListItem], next: String = "") {
        self.count = count
        self.results = results
        self.next = next
    }
}

// MARK: - ListItem
struct ListItem: Codable, Identifiable {
    let id = UUID()
    let name: String
    let url: String
    enum CodingKeys: String, CodingKey {
        case name, url
    }
}

struct HeldItem: Codable {
    let item: Species
    let versionDetails: [VersionDetail]
    
    enum CodingKeys: String, CodingKey {
        case item
        case versionDetails = "version_details"
    }
}

struct VersionDetail: Codable {
    let rarity: Int
    let version: Species
}

// MARK: - Pokemon
struct Pokemon: Codable, Identifiable {
    let abilities: [Ability]
    let baseExperience: Int
    let forms: [Species]
    let gameIndices: [GameIndex]
    let height: Int
    let heldItems: [HeldItem]
    let id: Int
    let isDefault: Bool
    let locationAreaEncounters: String
    let moves: [Move]
    let name: String
    let order: Int
    let pastTypes: [String]
    let species: Species
    let sprites: Sprites
    let stats: [Stat]
    let types: [TypeElement]
    let weight: Int
    
    enum CodingKeys: String, CodingKey {
        case abilities
        case baseExperience = "base_experience"
        case forms
        case gameIndices = "game_indices"
        case height
        case heldItems = "held_items"
        case id
        case isDefault = "is_default"
        case locationAreaEncounters = "location_area_encounters"
        case moves, name, order
        case pastTypes = "past_types"
        case species, sprites, stats, types, weight
    }
}

// MARK: - Ability
struct Ability: Codable {
    let ability: Species
    let isHidden: Bool
    let slot: Int
    
    enum CodingKeys: String, CodingKey {
        case ability
        case isHidden = "is_hidden"
        case slot
    }
}

// MARK: - Species
struct Species: Codable {
    let name: String
    let url: String
}

// MARK: - GameIndex
struct GameIndex: Codable {
    let gameIndex: Int
    let version: Species
    
    enum CodingKeys: String, CodingKey {
        case gameIndex = "game_index"
        case version
    }
}

// MARK: - Move
struct Move: Codable {
    let move: Species
    let versionGroupDetails: [VersionGroupDetail]
    
    enum CodingKeys: String, CodingKey {
        case move
        case versionGroupDetails = "version_group_details"
    }
}

// MARK: - VersionGroupDetail
struct VersionGroupDetail: Codable {
    let levelLearnedAt: Int
    let moveLearnMethod, versionGroup: Species
    
    enum CodingKeys: String, CodingKey {
        case levelLearnedAt = "level_learned_at"
        case moveLearnMethod = "move_learn_method"
        case versionGroup = "version_group"
    }
}

// MARK: - GenerationV
struct GenerationV: Codable {
    let blackWhite: Sprites
    
    enum CodingKeys: String, CodingKey {
        case blackWhite = "black-white"
    }
}

// MARK: - GenerationIv
struct GenerationIv: Codable {
    let diamondPearl, heartgoldSoulsilver, platinum: Sprites
    
    enum CodingKeys: String, CodingKey {
        case diamondPearl = "diamond-pearl"
        case heartgoldSoulsilver = "heartgold-soulsilver"
        case platinum
    }
}

// MARK: - Versions
struct Versions: Codable {
    let generationI: GenerationI
    let generationIi: GenerationIi
    let generationIii: GenerationIii
    let generationIv: GenerationIv
    let generationV: GenerationV
    let generationVi: [String: Home]
    let generationVii: GenerationVii
    let generationViii: GenerationViii
    
    enum CodingKeys: String, CodingKey {
        case generationI = "generation-i"
        case generationIi = "generation-ii"
        case generationIii = "generation-iii"
        case generationIv = "generation-iv"
        case generationV = "generation-v"
        case generationVi = "generation-vi"
        case generationVii = "generation-vii"
        case generationViii = "generation-viii"
    }
}

// MARK: - Sprites
class Sprites: Codable {
    let backDefault: String
    let backFemale: String?
    let backShiny: String
    let backShinyFemale: String?
    let frontDefault: String
    let frontFemale: String?
    let frontShiny: String
    let frontShinyFemale: String?
    let other: Other?
    let versions: Versions?
    let animated: Sprites?
    
    enum CodingKeys: String, CodingKey {
        case backDefault = "back_default"
        case backFemale = "back_female"
        case backShiny = "back_shiny"
        case backShinyFemale = "back_shiny_female"
        case frontDefault = "front_default"
        case frontFemale = "front_female"
        case frontShiny = "front_shiny"
        case frontShinyFemale = "front_shiny_female"
        case other, versions, animated
    }
    
}

// MARK: - GenerationI
struct GenerationI: Codable {
    let redBlue, yellow: RedBlue
    
    enum CodingKeys: String, CodingKey {
        case redBlue = "red-blue"
        case yellow
    }
}

// MARK: - RedBlue
struct RedBlue: Codable {
    let backDefault, backGray, backTransparent, frontDefault: String
    let frontGray, frontTransparent: String
    
    enum CodingKeys: String, CodingKey {
        case backDefault = "back_default"
        case backGray = "back_gray"
        case backTransparent = "back_transparent"
        case frontDefault = "front_default"
        case frontGray = "front_gray"
        case frontTransparent = "front_transparent"
    }
}

// MARK: - GenerationIi
struct GenerationIi: Codable {
    let crystal: Crystal
    let gold, silver: Gold
}

// MARK: - Crystal
struct Crystal: Codable {
    let backDefault, backShiny, backShinyTransparent, backTransparent: String
    let frontDefault, frontShiny, frontShinyTransparent, frontTransparent: String
    
    enum CodingKeys: String, CodingKey {
        case backDefault = "back_default"
        case backShiny = "back_shiny"
        case backShinyTransparent = "back_shiny_transparent"
        case backTransparent = "back_transparent"
        case frontDefault = "front_default"
        case frontShiny = "front_shiny"
        case frontShinyTransparent = "front_shiny_transparent"
        case frontTransparent = "front_transparent"
    }
}

// MARK: - Gold
struct Gold: Codable {
    let backDefault, backShiny, frontDefault, frontShiny: String
    let frontTransparent: String?
    
    enum CodingKeys: String, CodingKey {
        case backDefault = "back_default"
        case backShiny = "back_shiny"
        case frontDefault = "front_default"
        case frontShiny = "front_shiny"
        case frontTransparent = "front_transparent"
    }
}

// MARK: - GenerationIii
struct GenerationIii: Codable {
    let emerald: Emerald
    let fireredLeafgreen, rubySapphire: Gold
    
    enum CodingKeys: String, CodingKey {
        case emerald
        case fireredLeafgreen = "firered-leafgreen"
        case rubySapphire = "ruby-sapphire"
    }
}

// MARK: - Emerald
struct Emerald: Codable {
    let frontDefault, frontShiny: String
    
    enum CodingKeys: String, CodingKey {
        case frontDefault = "front_default"
        case frontShiny = "front_shiny"
    }
}

// MARK: - Home
struct Home: Codable {
    let frontDefault: String
    let frontFemale: String?
    let frontShiny: String
    let frontShinyFemale: String?
    
    enum CodingKeys: String, CodingKey {
        case frontDefault = "front_default"
        case frontFemale = "front_female"
        case frontShiny = "front_shiny"
        case frontShinyFemale = "front_shiny_female"
    }
}

// MARK: - GenerationVii
struct GenerationVii: Codable {
    let icons: DreamWorld
    let ultraSunUltraMoon: Home
    
    enum CodingKeys: String, CodingKey {
        case icons
        case ultraSunUltraMoon = "ultra-sun-ultra-moon"
    }
}

// MARK: - DreamWorld
struct DreamWorld: Codable {
    let frontDefault: String
    let frontFemale: String?
    
    enum CodingKeys: String, CodingKey {
        case frontDefault = "front_default"
        case frontFemale = "front_female"
    }
}

// MARK: - GenerationViii
struct GenerationViii: Codable {
    let icons: DreamWorld
}

// MARK: - Other
struct Other: Codable {
    let dreamWorld: DreamWorld
    let home: Home
    let officialArtwork: OfficialArtwork
    
    enum CodingKeys: String, CodingKey {
        case dreamWorld = "dream_world"
        case home
        case officialArtwork = "official-artwork"
    }
}

// MARK: - OfficialArtwork
struct OfficialArtwork: Codable {
    let frontDefault: String
    
    enum CodingKeys: String, CodingKey {
        case frontDefault = "front_default"
    }
}

// MARK: - Stat
struct Stat: Codable {
    let baseStat, effort: Int
    let stat: Species
    
    enum CodingKeys: String, CodingKey {
        case baseStat = "base_stat"
        case effort, stat
    }
}

// MARK: - TypeElement
struct TypeElement: Codable {
    let slot: Int
    let type: Species
}
  • Wow! This is so tremendously helpful. Lots for me to dig into here. So very kind of you to share a complete example. It means a great deal to me. All the best! – Gallaugher Oct 17 '22 at 19:49
  • This code seems to work great. One problem I'm having, though, is the ProgressView doesn't seem to be dismissing. If I click a NavigationLink it goes away & I can see the next page has loaded, but there must be something I'm not understanding about the code above. I copied & pasted code as-is, but did I miss something? Sorry to trouble you. Just want to make sure I have this down. Thanks! – Gallaugher Oct 18 '22 at 17:14
  • Yes I also noticed the `ProgressView()` can still be showing even though the next page has been fetched. I usually do a scroll and it dissapears. If you are concerned about it, you could use `ProgressView("Scroll for more")` instead, this tells the user that the data is being fetched and more will be available, when the server decides to send it to the app, and that can take some times. These more complex paginations as a function of the server performance, would probably need more code restructure. Note, I updated the `getPokemonList()` and the `PokemonList`. – workingdog support Ukraine Oct 19 '22 at 06:19