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