BLACK FRIDAY: Save 50% on all my Swift books and bundles! >>

LazyVGrid not updating correctly

Forums > SwiftUI

Hi all! I'm building my very first iOS app using Swift UI. It's an app to manage your gaming backlog so you can add games which you can assign a status to and they get added to the list. I've progressed quite a bit so far, but I'm stumbling upon two issues.

I have a file called GameCollectionView.swift which is basically the grid view to view your games. It also has filter and sorting options for the games within the grid. This file imports the GameCellView which are the individual games with their metadata. Long pressing a game cell allows the user to do three things:

  • editing status
  • adding to hall of fame
  • removing the game

This is all works as it should except to a certain point. When my grid gets quite long, it's not letting me remove some games. Also, the game cover (which is fetched in the background upon game save) also doesn't get displayed correcly. Only when I scroll past the item or change the status, then it loads the cover and allows me to remove the game. Very strange if you ask me.

However, when I switch from a LazyVGrid to a regular VStack everything works correctly! I've attached my code as well as a video showing the issue. Any help would be much much appreciated!

Video Link to YouTube

GameCollectionView.swift

import SwiftData
import SwiftUI

enum SortOption: String, CaseIterable {
    case alphabeticalAZ = "Alphabetically (A→Z)"
    case alphabeticalZA = "Alphabetically (Z→A)"
    case newestFirst = "Newest First"
    case oldestFirst = "Oldest First"
}

struct GameCollectionView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var games: [Game]

    @State private var selectedGame: Game?
    @AppStorage("showPlatform") private var showPlatform = true
    @AppStorage("wideArtwork") private var wideArtwork = false
    @AppStorage("selectedPlatformRawValue") private var selectedPlatformRawValue: String?
    @AppStorage("selectedStatusRawValue") private var selectedStatusRawValue: String?
    @AppStorage("sortOptionRawValue") private var sortOptionRawValue = SortOption.alphabeticalAZ.rawValue
    @State private var isShowingDeleteAllAlert = false
    @State private var isShowingAddSheet = false
    @State private var isShowingGameDetailView = false
    @State private var isShowingHallOfFameSheet = false

    @State private var showingDebugLoadGamesAlert = false
    @State private var debugLoadGamesAlertMessage = ""

    private var selectedPlatform: Platform? {
        selectedPlatformRawValue.flatMap { Platform(rawValue: $0) }
    }

    private var selectedStatus: GameStatus? {
        selectedStatusRawValue.flatMap { GameStatus(rawValue: $0) }
    }

    private var sortOption: SortOption {
        SortOption(rawValue: sortOptionRawValue) ?? .alphabeticalAZ
    }

    var body: some View {
        NavigationView {
            Group {
                if games.isEmpty {
                    EmptyStateView(isShowingAddSheet: $isShowingAddSheet)
                } else if filteredGames.isEmpty {
                    NoResultsView(clearFilters: clearFilters)
                } else {
                    gameCollectionView
                }
            }
            .navigationTitle("Games")
            .toolbar {
                if !games.isEmpty {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Menu {
                            Group {
                                Section("Filter") {
                                    platformFilterMenu
                                    statusFilterMenu
                                }

                                Section {
                                    sortMenu
                                }

                                Section {
                                    customizeMenu
                                }

                                Section {
                                    debugMenu
                                }
                            }
                        } label: {
                            Label("Options", systemImage: "ellipsis.circle")
                        }
                    }
                }

                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        isShowingAddSheet = true
                    }) {
                        Image(systemName: "plus.circle.fill")
                    }
                }
            }
        }
        .sheet(isPresented: $isShowingAddSheet) {
            AddGameView(isPresented: $isShowingAddSheet)
        }
        .sheet(isPresented: $isShowingHallOfFameSheet) {
            HallOfFameView(isPresented: $isShowingHallOfFameSheet)
        }
        .sheet(item: $selectedGame) { game in
            NavigationView {
                GameDetailView(game: game, isPresented: $isShowingGameDetailView)
            }
        }
        .alert(isPresented: $showingDebugLoadGamesAlert) {
            Alert(title: Text("Sample Games"), message: Text(debugLoadGamesAlertMessage), dismissButton: .default(Text("OK")))
        }
        .alert("Delete All Games", isPresented: $isShowingDeleteAllAlert) {
            Button("Cancel", role: .cancel) {}
            Button("Delete", role: .destructive, action: deleteAllGames)
        } message: {
            Text("Are you sure you want to delete all games? This action cannot be undone.")
        }
    }

    private var platformFilterMenu: some View {
        Menu {
            Picker(selection: Binding(
                get: { self.selectedPlatform },
                set: { self.updateSelectedPlatform($0) }
            ), label: Text("Platform")) {
                Text("All Platforms").tag(nil as Platform?)

                Divider()

                ForEach(Platform.allCases, id: \.self) { platform in
                    Text(platform.rawValue).tag(platform as Platform?)
                }
            }
            .pickerStyle(InlinePickerStyle())
            .labelStyle(TitleOnlyLabelStyle())
        } label: {
            Label("Platform", systemImage: "circle.grid.cross")
            Text(selectedPlatform?.rawValue ?? "All Platforms")
        }
    }

    private func updateSelectedPlatform(_ newValue: Platform?) {
        selectedPlatformRawValue = newValue?.rawValue
    }

    private var statusFilterMenu: some View {
        Menu {
            Picker(selection: Binding(
                get: { self.selectedStatus },
                set: { self.updateSelectedStatus($0) }
            ), label: Text("Status")) {
                Text("All Statuses").tag(nil as GameStatus?)

                Divider()

                ForEach(GameStatus.allCases, id: \.self) { status in
                    Label {
                        Text(status.rawValue)
                    } icon: {
                        Image(systemName: status.icon)
                    }
                    .tag(status as GameStatus?)
                }
            }
            .pickerStyle(InlinePickerStyle())
            .labelStyle(IconOnlyLabelStyle())
        } label: {
            Label("Status", systemImage: selectedStatus?.icon ?? "circle")
            Text(selectedStatus?.rawValue ?? "All Statuses")
        }
    }

    private func updateSelectedStatus(_ newValue: GameStatus?) {
        selectedStatusRawValue = newValue?.rawValue
    }

    private var sortMenu: some View {
        Menu {
            Picker(selection: Binding(
                get: { self.sortOption },
                set: { self.updateSortOption($0) }
            ), label: Text("Sort By")) {
                ForEach(SortOption.allCases, id: \.self) { option in
                    HStack {
                        Text(option.rawValue)
                    }
                    .tag(option)
                }
            }
        } label: {
            Label("Sort By", systemImage: "arrow.up.arrow.down")
            Text(sortOption.rawValue)
        }
    }

    private func updateSortOption(_ newValue: SortOption) {
        sortOptionRawValue = newValue.rawValue
    }

    private var customizeMenu: some View {
        Menu {
            Toggle("Show Platform Label", systemImage: "ellipsis.rectangle", isOn: $showPlatform.animation(.easeInOut(duration: 0.2)))
            Toggle("Wide Game Artwork", systemImage: "rectangle.portrait.arrowtriangle.2.outward", isOn: $wideArtwork.animation(.easeInOut(duration: 0.2)))
            // Add more customization options here in the future
        } label: {
            Label("Customize", systemImage: "paintbrush.pointed")
        }
    }

    private var debugMenu: some View {
        Menu {
            Menu {
                Button("25 Games", action: { loadSampleGames(count: 25) })
                Button("100 Games", action: { loadSampleGames(count: 100) })
            } label: {
                Label("Load Sample Games", systemImage: "square.and.arrow.down")
            }

            Button(role: .destructive, action: {
                isShowingDeleteAllAlert = true
            }) {
                Label("Remove All Games", systemImage: "trash")
            }
        } label: {
            Label("Debug", systemImage: "ladybug")
        }
    }

    private func loadSampleGames(count: Int) {
        do {
            let loadedCount = try SampleGamesLoader.shared.loadSampleGames(count: count, into: modelContext)
            debugLoadGamesAlertMessage = "Successfully loaded \(loadedCount) games"
            print(debugLoadGamesAlertMessage)
            showingDebugLoadGamesAlert = true
        } catch {
            debugLoadGamesAlertMessage = "Error loading sample games: \(error.localizedDescription)"
            print(debugLoadGamesAlertMessage)
            showingDebugLoadGamesAlert = true
        }
    }

    private func deleteAllGames() {
        for game in games {
            modelContext.delete(game)
        }
        try? modelContext.save()
    }

    private var hallOfFameCell: some View {
        Button(action: {
            isShowingHallOfFameSheet = true
        }) {
            HStack {
                Image(systemName: "star.fill")
                    .foregroundColor(.yellow)
                Text("Hall of Fame")
                    .font(.headline)
                Spacer()
                Text("\(games.filter { $0.isHallOfFame }.count)")
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    .contentTransition(.numericText())
                    .animation(.default, value: games.filter { $0.isHallOfFame }.count)
                Image(systemName: "chevron.right")
                    .foregroundColor(.secondary)
            }
            .padding()
            .background(Color(.secondarySystemBackground))
            .cornerRadius(10)
        }
        .buttonStyle(PlainButtonStyle())
    }

    private var gameCollectionView: some View {
        ScrollView {
            VStack(spacing: 20) {
                hallOfFameCell

                VStack(alignment: .leading, spacing: 20) {
                    Text("Collection")
                        .font(.title3)
                        .fontWeight(.semibold)
                    LazyVGrid(columns: [
                        GridItem(.adaptive(minimum: 150), spacing: 20, alignment: .top),
                    ],
                    alignment: .leading,
                    spacing: 20) {
                        ForEach(filteredGames) { game in
                            GameCell(game: game, showPlatform: showPlatform, wideArtwork: wideArtwork, onDelete: {
                                withAnimation(.easeInOut(duration: 0.3)) {
                                    deleteGame(game)
                                }
                            })
                            .onTapGesture {
                                selectedGame = game
                            }
                            .id(game.id)
                            .transition(.asymmetric(
                                insertion: .scale.combined(with: .opacity),
                                removal: .scale.combined(with: .opacity)
                            ))
                        }
                    }
                }
            }
            .padding()
            .animation(.easeInOut(duration: 0.2), value: showPlatform)
            .animation(.easeInOut(duration: 0.2), value: wideArtwork)
        }
        .animation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0), value: filteredGames)
    }

    private func deleteGame(_ game: Game) {
        withAnimation {
            modelContext.delete(game)
            do {
                try modelContext.save()
            } catch {
                print("Error deleting game: \(error.localizedDescription)")
            }
        }
    }

    private var filteredGames: [Game] {
        games.filter { game in
            (selectedPlatform == nil || game.platform == selectedPlatform) &&
                (selectedStatus == nil || game.status == selectedStatus)
        }.sorted { lhs, rhs in
            switch sortOption {
            case .alphabeticalAZ:
                return lhs.title < rhs.title
            case .alphabeticalZA:
                return lhs.title > rhs.title
            case .newestFirst:
                return lhs.dateAdded > rhs.dateAdded
            case .oldestFirst:
                return lhs.dateAdded < rhs.dateAdded
            }
        }
    }

    private func clearFilters() {
        selectedPlatformRawValue = nil
        selectedStatusRawValue = nil
    }
}

#Preview {
    GameCollectionView()
        .modelContainer(for: Game.self, inMemory: true)
}

   

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.