Search code examples
swiftswiftuiios14

Inconsistent icon toggling in buttons while using LazyVGrid or LazyVStack


I am currently trying to learn SwiftUI by implementing a small project. For this I list video games with their covers and titles (GameCard View) in a LazyVGrid (GameList View). The display and the asynchronous loading of the images from a URL are already working great.

Now I try to integrate a favorites functionality. For this purpose I show a button in every GameCard View, which shows a filled or an empty heart, depending on whether the Game ID is available in the UserDefaults. The action of the button works at any time: if the Game ID is already in the User Defaults, the ID is removed. If it is not yet present, it will be added. This can be easily observed with prints(). What does not work so well is the toggling of the heart icon: if I scroll the LazyVGrid a bit further down into areas that were not visible initially and press the button, it sometimes (not always!) happens that the icon is not replaced (even though the action was triggered).

My guess is that this is due to the LazyVGrid or LazyVStack. If I display the GameCards in a normal VStack, I cannot reproduce the phenomenon.

Do you have an idea how to solve this or what is the reason for this?

See some implementation snippets below for more context. The button I am talking about is located in GameCard.swift and contains the Image(systemName: favorites.contains(self.game) ? "heart.fill" : "heart")

GameListView.swift

struct GameListView: View {

@Binding var loadGames: Bool

@ObservedObject var gameList: GameList = GameList()

@ObservedObject var favorites = Favorites()

@State private var selectedGame: Game? = nil

@State private var searchText = ""

init(loadGames: Binding<Bool>) {
    self._loadGames = loadGames     
    ...
}

let layout = [
    GridItem(.flexible(), spacing: 16),
    GridItem(.flexible(), spacing: 16)
]


var body: some View {
        NavigationView {
            ZStack {
                Color.black
                    .edgesIgnoringSafeArea(.all)
            ScrollView {
                SearchBarView(searchText: $searchText)
                    .padding(.top, 16.0)
                if gameList.isLoading {
                    Text("Loading")
                        .foregroundColor(Color.white)
                } else {
                LazyVGrid(columns: layout, spacing: 16) {
                    ForEach(gameList.games.filter{$0.name.contains(searchText) || searchText == ""}) {game in
                        GameCard(game: game)
                            .onTapGesture {
                                self.selectedGame = game
                                print(self.selectedGame!)
                            }
                    }
                }
                .sheet(item: $selectedGame) { game in
                    GameDetail(game: game)
                    }
                .padding(.all)
                .background(Color.black)
                .edgesIgnoringSafeArea(.all)
                .navigationBarTitle("Upcoming Games")
                .resignKeyboardOnDragGesture()
                }
            }
            }
        }.onAppear {
            if loadGames {
                self.gameList.reload()
                loadGames = false
            }
        }
        .environmentObject(favorites)
    }
}

GameCard.swift

struct GameCard: View {

var game: Game

@Environment(\.imageCache) var cache: ImageCache

@EnvironmentObject var favorites: Favorites

var body: some View {
        VStack(alignment: .leading) {
            ZStack(alignment: .topTrailing) {
                AsyncImage(
                   url: game.coverURL!,
                   cache: self.cache,
                   placeholder: Text(game.name),
                   configuration: { $0.resizable() }
                )
                .cornerRadius(4.0)
                .aspectRatio(contentMode: .fit)
                Button(action: {
                    if self.favorites.contains(self.game) {
                        print("Remove Game from Favs")
                        self.favorites.remove(self.game)
                    } else {
                        print("Add Game to Favs")
                        self.favorites.add(self.game)
                    }
                }) {
                    Image(systemName: favorites.contains(self.game) ? "heart.fill" : "heart")
                        .imageScale(.large)
                }
                .padding([.top, .trailing])
            }
            Text(game.name)
                .font(.body)
                .foregroundColor(Color.white)
                .fontWeight(.semibold)
                .lineLimit(1)
                .lineSpacing(32)
                .padding(.bottom, 0.5)
            Text(game.releaseDateText)
                .font(.subheadline)
                .foregroundColor(Color.gray)
                .lineLimit(0)
        }
        .padding(.all, 8.0)
        .background(Color(red: 1.0, green: 1.0, blue: 1.0, opacity: 0.15))
        .cornerRadius(8.0)
    }
}

Update: I added the implementation of Favorites.swift. This is where the view changes should be triggered.

class Favorites: ObservableObject {

//The fetched games by id are stored here.
@Published var favGames: [Game] = []

@Published var isLoading = false

var gameService = Store.shared

let userDefaults = UserDefaults.standard

// the key we're using to read/write in UserDefaults
private let saveKey = "Favorites"

// the actual game ids the user has favorited
var games: [String]

init() {
    // load our saved data
    self.games = userDefaults.stringArray(forKey: saveKey) ?? []
}

// returns true if set contains the game
func contains(_ game: Game) -> Bool {
    return games.contains(String(game.id))
}

// adds gams to set, updates all views, and saves the change
func add(_ game: Game) {
    objectWillChange.send()
    games.append(String(game.id))
    save()
}

// removes the game from  set, updates all views, and saves the change
func remove(_ game: Game) {
    objectWillChange.send()
    games.remove(object: String(game.id))
    save()
}

func save() {
    // write out our data
    UserDefaults.standard.set(self.games, forKey: saveKey)
    print(games)
    print("Saved new set of favorites")
}
    
func reload() {
    self.favGames = []
    self.isLoading = true
            
    gameService.fetchGamesById(id: self.games) { [weak self]  (result) in
        self?.isLoading = false

        switch result {
        case .success(let games):
            self?.favGames = games

        case .failure(let error):
            print(error.localizedDescription)
        }
      }
   }
}

extension Array where Element: Equatable {

// Remove first collection element that is equal to the given `object`:
mutating func remove(object: Element) {
    guard let index = firstIndex(of: object) else {return}
    remove(at: index)
    }

}

Solution

  • By actually posting and reviewing the Favorites.swift implementation (thank you @Asperi) I think I found the issue (at least it is no longer reproducible):

    objectWillSend.send() was called before games.append(String(game.id)) and games.remove(String(game.id))in the add and remove functions which were called by the button action in GameCard.swift. I think this caused the view to be updated in some cases before the game was even added or removed from the favorites.

    I am just wondering now, why this was not the case in regular VStacks. Perhaps someone can elaborate on this?