crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit a330dabc8067d55568ce9a26a1b747b88bfe2fdb
parent 61e4e1cd6e9120d52011417586a63ebf797e17f3
Author: Michael Camilleri <[email protected]>
Date:   Wed,  8 Jul 2026 13:00:41 +0900

Cache Game List sections outside the render path

The Game List was still deriving its visible sections inside the view
builder: every invalidation rebuilt summaries, sorted in-progress,
revoked, and completed games, filtered blocked invitations, and sliced
the completed page even when the change was only layout, a banner, or
another unrelated view state update.

This commit keeps a prepared GameListSections snapshot in view-local
state and rebuilds it when the Core Data context changes or when the
local player name or colour changes. The existing completed pagination
behaviour is preserved by expanding the visible count when new completed
games arrive and rebuilding the snapshot when 'Load More' is tapped.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Views/GameList/GameListView.swift | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
1 file changed, 61 insertions(+), 21 deletions(-)

diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -62,6 +62,7 @@ struct GameListView: View { @State private var showingNamePrompt = false @State private var nameDraft = "" @State private var summaryCache = GameSummaryCache() + @State private var gameListSections = GameListSections() @State private var completedVisibleCount = completedPageSize /// Shows the "Never show me tips" opt-out in the banner slot for a few /// seconds after a tip is dismissed; cleared by the timer or by tapping it. @@ -72,6 +73,15 @@ struct GameListView: View { private static let completedPageSize = 7 + private struct GameListSections { + var invites: [InviteEntity] = [] + var inProgress: [GameSummary] = [] + var revoked: [GameSummary] = [] + var completed: [GameSummary] = [] + var completedTotalCount = 0 + var hasMoreCompleted = false + } + var body: some View { VStack(spacing: 0) { accessibilityHeading @@ -94,7 +104,7 @@ struct GameListView: View { .padding(.top, 8) .transition(.opacity) } - content(usesRoomierType: usesRoomierType) + content(sections: gameListSections, usesRoomierType: usesRoomierType) } .background(Color(.systemGroupedBackground)) .animation(.easeInOut(duration: 0.3), value: announcements.currentGlobal()) @@ -104,6 +114,9 @@ struct GameListView: View { } action: { height in containerHeight = height } + .onAppear { + rebuildGameListSections() + } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -180,11 +193,23 @@ struct GameListView: View { .onChange(of: pendingInvites.count) { _, _ in reconcilePendingInviteNotification() } + .onChange(of: preferences.name) { _, _ in + rebuildGameListSections() + } + .onChange(of: preferences.colorID) { _, _ in + rebuildGameListSections() + } .onChange(of: isVoiceOverEnabled) { _, enabled in if enabled { focusPuzzleListHeading() } } + .onReceive(NotificationCenter.default.publisher( + for: .NSManagedObjectContextObjectsDidChange, + object: viewContext + )) { _ in + rebuildGameListSections() + } .onDisappear { onDisappear() } @@ -368,8 +393,17 @@ struct GameListView: View { } } - @ViewBuilder - private func content(usesRoomierType: Bool) -> some View { + private func rebuildGameListSections() { + let next = makeGameListSections(completedLimit: completedVisibleCount) + if next.completedTotalCount > gameListSections.completedTotalCount { + completedVisibleCount += next.completedTotalCount - gameListSections.completedTotalCount + gameListSections = makeGameListSections(completedLimit: completedVisibleCount) + } else { + gameListSections = next + } + } + + private func makeGameListSections(completedLimit: Int) -> GameListSections { let summaries = games.compactMap { summaryCache.summary( for: $0, @@ -387,9 +421,7 @@ struct GameListView: View { let completed = summaries .filter { $0.completedAt != nil && !$0.isAccessRevoked } .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } - let visibleCount = min(completedVisibleCount, completed.count) - let visibleCompleted = Array(completed.prefix(visibleCount)) - let hasMore = visibleCount < completed.count + let visibleCompletedCount = min(completedLimit, completed.count) let blockedIDs = Set(blockedFriends.compactMap { $0.authorID }) let visibleInvites = pendingInvites.filter { @@ -397,23 +429,35 @@ struct GameListView: View { return !blockedIDs.contains(inviter) } + return GameListSections( + invites: visibleInvites, + inProgress: inProgress, + revoked: revoked, + completed: Array(completed.prefix(visibleCompletedCount)), + completedTotalCount: completed.count, + hasMoreCompleted: visibleCompletedCount < completed.count + ) + } + + @ViewBuilder + private func content(sections: GameListSections, usesRoomierType: Bool) -> some View { Group { if horizontalSizeClass == .regular { gridLayout( - invites: visibleInvites, - inProgress: inProgress, - revoked: revoked, - completed: visibleCompleted, - hasMore: hasMore, + invites: sections.invites, + inProgress: sections.inProgress, + revoked: sections.revoked, + completed: sections.completed, + hasMore: sections.hasMoreCompleted, usesRoomierType: usesRoomierType ) } else { listLayout( - invites: visibleInvites, - inProgress: inProgress, - revoked: revoked, - completed: visibleCompleted, - hasMore: hasMore, + invites: sections.invites, + inProgress: sections.inProgress, + revoked: sections.revoked, + completed: sections.completed, + hasMore: sections.hasMoreCompleted, usesRoomierType: usesRoomierType ) } @@ -445,11 +489,6 @@ struct GameListView: View { .background(Color(.systemGroupedBackground)) } } - .onChange(of: completed.count) { oldCount, newCount in - if newCount > oldCount { - completedVisibleCount += (newCount - oldCount) - } - } } // MARK: - List layout (compact width / iPhone) @@ -620,6 +659,7 @@ struct GameListView: View { Button { withAnimation(.easeInOut(duration: 0.25)) { completedVisibleCount += Self.completedPageSize + rebuildGameListSections() } } label: { Text("Load More")