SwiftUI List containing AsyncImages only updates when scrolled out of view

397 views Asked by At

I have a List of Pokemon objects, displayed in PokemonCells. When these PokemonCells are created, it checks if it has a PokemonDetail object. The PokemonDetail is an @State var. If it's nil, it triggers a ProgressView and a call to fetch the pokemondetails. It stores the results of this fetch into the PokemonDetail @State var.

As soon as this @State var is assigned, it triggers the call to retrieve an AsyncImage, showing a placeholder whilst it's working.

struct PokemonCell: View {

  let pokemon: Pokemon

  //This is nil when the view is first instantiated.
  @State var pokemonDetail: Result<PokemonDetail, PokemonAPI.RequestError>?

  var body: some View {
    HStack {
      //If pokemonDetail not nill, enter switch
      if let pokemonDetail = pokemonDetail {
        //Switch on the pokemondetail/pokemonapi.requesterror object.
        switch pokemonDetail {
        //If result from API is .success, get asyncimage
        case .success(let newPoke):
          //Create the asyncImage using the URL contained in the pokemonDetail object.
          AsyncImage(
            url: URL(string: newPoke.spriteImageURL),
            transaction: .init(animation: .spring(response: 1.6))
          ) { phase in
            switch phase {
            case .empty:
              ProgressView()
                .progressViewStyle(.circular)
            case .success(let image):
              image
                .resizable()
                .aspectRatio(contentMode: .fill)
            case .failure:
              Text("Failed fetching image try again.")
                .foregroundColor(.red)
            @unknown default:
              Text("Unknown error. Please try again.")
                .foregroundColor(.red)
            }
          }
          .frame(width: 50, height: 50)

        case .failure(_):
          Text("ImgError")

        }
      }
      //If it IS null, trigger the progressview and fetch the details.
      else {
        ProgressView().onAppear(perform: fetchPokemonDetails)
      }

      VStack(alignment: .leading) {
        Text(pokemon.name).font(.title)

      }
    }
  }

  //Fetch details from API, store the Result<PokemonDetail, PokemonAPI.RequestError> object in the pokemonDetail @State var
  func fetchPokemonDetails() {
    self.pokemonDetail = nil
    PokemonAPI.shared.getPokemonDetails(name: pokemon.name) { result in self.pokemonDetail = result
    }
  }
}

The strange thing is that this works fine for the First image, then for the rest of the images it keeps showing the placeholder image. It's only when the image is scrolled off-screen, that it triggers the FetchPokemonDetails, and updates the image properly.

In the Contentview where the Cells are created, I use fairly similar code to retrieve the list of pokemon names, and it works without issue..

var body: some View {

    if let pokemonsState = pokemonsState {

      switch pokemonsState {

      case .success(let pokemons):

        NavigationView {
          List(pokemonsList) { pokemon in
            NavigationLink(destination: PokemonDetailView(pokemon: pokemon)) {
              PokemonCell(pokemon: pokemon).onAppear {
                if pokemon.name == pokemons.last?.name {
                  offset += 10

                  fetchPokemon()

                }
              }
            }

          }.navigationTitle("Pokemons")
        }

      default: Text("pholder")
      }

    } else {
      ProgressView().onAppear(perform: fetchPokemon)
    }

  }

Here is the API code

     func getPokemonDetails(
        name: String, completion: @escaping (Result<PokemonDetail, RequestError>) -> Void
      ) {
          print("api called")
        let url = URL(string: "https://pokeapi.co/api/v2/pokemon/\(name)")!
        let urlRequest = URLRequest(url: url)
          print("url completed: \(urlRequest)")
        cancellable = URLSession.shared.dataTaskPublisher(for: urlRequest)
          .map({ $0.data })
          .decode(type: PokemonDetail.self, decoder: JSONDecoder())
          .receive(on: DispatchQueue.main)
          .sink(receiveCompletion: { result in
            switch result {
            case .finished:
              print("pokemondetails fetched: \(result) <-- this a result in finished case")
              break
            case .failure(let error):
              switch error {
              case let urlError as URLError:
                completion(.failure(.urlError(urlError)))
                  print("pokemondetails fetched: \(result) <-- this a result in error case")
                print("apierror")
              case let decodingError as DecodingError:
                completion(.failure(.decodingError(decodingError)))
                print("apierror")
                  print("pokemondetails fetched: \(result) <-- this a result in error case")
              default:
                completion(.failure(.genericError(error)))
                  print("pokemondetails fetched: \(result) <-- this a result in error case")
    
              }
            }
    
          }
    
          //) { (response) in print("apiclass result: \(completion(.success(response)))")}
          ) { (response) in print("apiclass result: \(completion(.success(response)))"); completion(.success(response))   }
    
      }

I messed around with this for all of yesterday evening. I tried replacing the List with a Scrollview + LazyVstack. Different ways of instantiating the AsyncImage.

I Thought it could be because the API is calling all of the images at once, and then being refused by the image server. But I don't see failures, and even reducing the number of pokemons in the list to 2 or 3 results in the same. There's nothing wrong with the image requests themselves either, because they do work fine after they're scrolled off-screen.

Placing a print statement in the FetchPokemonDetails function and in the API shows that the API is indeed called for each pokemon. getPokemonDetails is called, the URL is created successfully, but then it just stops. It does not hit any of the .finished or .error cases, returns a nil result, and does not hit the print statement that prints the result at the end.

func fetchPokemonDetails() {
    self.pokemonDetail = nil
      print("fetching for \(pokemon.name)")
    PokemonAPI.shared.getPokemonDetails(name: pokemon.name) { result in pokemonDetail = result
    }
      print("fetched, now pokemonDetail = \(pokemonDetail)")
  }

Results in the following output for all pokemon (even for the first one that IS successful):

fetching for ivysaur
api called
url completed: https://pokeapi.co/api/v2/pokemon/ivysaur
fetched, now pokemonDetail = nil
fetching for bulbasaur
api called
url completed: https://pokeapi.co/api/v2/pokemon/bulbasaur
fetched, now pokemonDetail = nil
apiclass result: ()
pokemondetails fetched: finished <-- this a result in finished case

It looks like the getPokemonDetails API is just interrupted by the next call and completely forgets to finish what it's doing. How do I fix this?

1

There are 1 answers

0
burnsi On

It looks like the getPokemonDetails API is just interrupted by the next call and completely forgets to finish what it's doing

An explanation for this behaviour would be the way you store your cancellable:

cancellable = URLSession.shared.dataTaskPublisher.....

There is not much information about the cancellable var here, but the observed behaviour suggests you are overwritting this var everytime you create a new request. If the cancellabe is not stored your request goes out of scope.

To solve this you could try:

var cancellables = Set<AnyCancellable>()

and then in your func:

cancelables.append(URLSession.shared.dataTaskPublisher.....

But there may be more going on here. Hard to tell.