From fc5477f1696dffb766ad10b4535d68b9c2f0e632 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 20:18:41 -0700 Subject: [PATCH 1/3] remove the anyview decoration chain in contentview body wrapped the board in anyview and threaded it through toolbar, sheet, and observer helpers that each took and returned anyview, erasing the tree's structural identity and forcing swiftui to diff the whole scene through an opaque box on every update. the helpers are generic over their content now; their bodies are unchanged. --- .../Views/Shared/ContentView.swift | 60 ++++++++----------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 7e44045..8873b7f 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -201,12 +201,10 @@ struct ContentView: View { var body: some View { sceneDecorations( - for: AnyView( - GeometryReader { geometry in - boardRoot(for: geometry) - } - .environment(\.cardStyle, currentCardStyle) - ) + for: GeometryReader { geometry in + boardRoot(for: geometry) + } + .environment(\.cardStyle, currentCardStyle) ) .accessibilityHidden(isShowingGamePicker) .overlay { @@ -233,15 +231,17 @@ struct ContentView: View { } } - private func sceneDecorations(for baseView: AnyView) -> some View { + // The decoration helpers are generic over their content — never AnyView. + // Type erasure here would strip the board's structural identity, forcing + // SwiftUI to diff the whole scene through an opaque box on every update. + private func sceneDecorations(for baseView: some View) -> some View { let toolbarView = applyToolbar(to: baseView) let sheetsView = applySheets(to: toolbarView) return applyObservers(to: sheetsView) } - private func applyToolbar(to view: AnyView) -> AnyView { - AnyView( - view + private func applyToolbar(to view: some View) -> some View { + view .toolbar { #if os(iOS) ToolbarItem(placement: .bottomBar) { @@ -381,7 +381,6 @@ struct ContentView: View { } #endif } - ) } private func gameModePickerEntries() -> [GameModePickerView.Entry] { @@ -414,18 +413,16 @@ struct ContentView: View { switchGame(to: mode) } - private func applySheets(to view: AnyView) -> AnyView { + private func applySheets(to view: some View) -> some View { #if os(iOS) - let view = AnyView( - view.sheet(isPresented: $isShowingSettings) { - NavigationStack { - SettingsView() - } + let view = view.sheet(isPresented: $isShowingSettings) { + NavigationStack { + SettingsView() } - ) + } #endif - return AnyView( - view.sheet(isPresented: $isShowingRulesAndScoring) { + return view + .sheet(isPresented: $isShowingRulesAndScoring) { NavigationStack { RulesAndScoringView(initialSection: rulesAndScoringInitialSection) } @@ -435,26 +432,20 @@ struct ContentView: View { // all-games overview into per-game detail on both platforms. StatisticsView(viewModel: viewModel, initialMode: viewModel.gameMode) } - ) } - private func applyObservers(to view: AnyView) -> AnyView { + private func applyObservers(to view: some View) -> some View { #if os(iOS) - let view = AnyView( - view.onReceive(NotificationCenter.default.publisher(for: .openSettings)) { _ in - isShowingSettings = true - } - ) + let view = view.onReceive(NotificationCenter.default.publisher(for: .openSettings)) { _ in + isShowingSettings = true + } #endif - let commandObservedView = AnyView( - view + let commandObservedView = view .onReceive(NotificationCenter.default.publisher(for: .openRulesAndScoring)) { _ in presentRulesAndScoring(initialSection: .rules) } - ) - let gameStateObservedView = AnyView( - commandObservedView + let gameStateObservedView = commandObservedView .onChange(of: gameVariantRawValue) { _, newValue in guard hasLoadedGame, !isHydratingGame else { return } let variant = GameVariant(rawValue: newValue) ?? .klondike @@ -517,10 +508,8 @@ struct ContentView: View { processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() } - ) - return AnyView( - gameStateObservedView + return gameStateObservedView .onChange(of: scenePhase) { _, _ in syncLifecyclePauseState() } @@ -540,7 +529,6 @@ struct ContentView: View { .focusedSceneValue(\.gameMenuActions, gameMenuActions) .focusedSceneValue(\.gameMenuState, gameMenuState) #endif - ) } @ViewBuilder From 4868220533cc1afa824576a4cee01603a6586dc6 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 20:18:41 -0700 Subject: [PATCH 2/3] confine per-frame drag state to the drag overlay the drag gesture wrote dragtranslation and activetarget into contentview state on every frame, re-evaluating the entire board tree at gesture rate. the drag cluster now lives in an observable draginteractioncontroller read only by dragoverlayview, so per-frame writes re-render just the overlay; activetarget keeps its crossing-only invalidation through a guarded setter. the waste-return anchor frame fusion moves into the overlay with the state it reads, and the overlay's unused cardtilts parameter is dropped. --- .../DragInteractionController.swift | 56 +++++ .../Views/Shared/BoardOverlayViews.swift | 45 +++-- .../Views/Shared/ContentView.swift | 191 +++++++----------- 3 files changed, 159 insertions(+), 133 deletions(-) create mode 100644 ComputerSolitaire/Interaction/DragInteractionController.swift diff --git a/ComputerSolitaire/Interaction/DragInteractionController.swift b/ComputerSolitaire/Interaction/DragInteractionController.swift new file mode 100644 index 0000000..cb364e9 --- /dev/null +++ b/ComputerSolitaire/Interaction/DragInteractionController.swift @@ -0,0 +1,56 @@ +import CoreGraphics +import Foundation +import Observation + +/// The drag gesture's fast-changing state, extracted from ContentView so that +/// per-frame writes invalidate only the views that read them — DragOverlayView +/// — instead of the whole board tree. Deliberately a state bag, not an +/// orchestrator: the drop/return/auto-move flows stay on ContentView, which +/// fuses this state with card frames, tilts, sounds, and the session. +@MainActor +@Observable +final class DragInteractionController { + // Written on every gesture frame; read only by DragOverlayView. + var dragTranslation: CGSize = .zero + var overlayTilt: Double = 0 + + // Drop/return transition state; changes at flight boundaries. + var dragReturnOffset: CGSize = .zero + var isReturningDrag = false + var returningCards: [Card] = [] + var isDroppingCards = false + var droppingSelection: Selection? + var dropAnimationOffset: CGSize = .zero + var pendingDropDestination: Destination? + var wasteReturnAnchorCardID: UUID? + var wasteReturnAnchorFrame: CGRect? + + private(set) var activeTarget: DropTarget? + + /// The gesture calls this every frame. Unlike `@State`, `@Observable` + /// fires on every set with no equal-value dedupe, and the board rows read + /// `activeTarget` for drop highlighting — the guard keeps their + /// invalidation to actual target crossings. + func setActiveTarget(_ target: DropTarget?) { + guard target != activeTarget else { return } + activeTarget = target + } + + /// Clears every field, so a game switch or new deal can never leave a + /// stale in-flight drag behind. Mirrors the drag portion of ContentView's + /// `resetTransientBoardState()`. + func reset() { + setActiveTarget(nil) + dragTranslation = .zero + overlayTilt = 0 + dragReturnOffset = .zero + isReturningDrag = false + returningCards = [] + isDroppingCards = false + droppingSelection = nil + dropAnimationOffset = .zero + pendingDropDestination = nil + wasteReturnAnchorCardID = nil + wasteReturnAnchorFrame = nil + } +} diff --git a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift index 8043c08..6667945 100644 --- a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift @@ -108,23 +108,18 @@ private struct DrawOverlayCardView: View { struct DragOverlayView: View { @Bindable var viewModel: SolitaireViewModel + /// The gesture's fast-changing state. Read here — and only here — so the + /// per-frame translation writes re-render just this overlay, never the + /// board tree behind it. + let drag: DragInteractionController let cardFrames: [UUID: CGRect] - let cardTilts: [UUID: Double] - let dragTranslation: CGSize - let dragReturnOffset: CGSize - let isReturningDrag: Bool - let returningCards: [Card] - let isDroppingCards: Bool - let droppingCards: [Card] - let dropAnimationOffset: CGSize - let overlayTilt: Double var body: some View { Group { - if isDroppingCards { - dragCards(droppingCards, additionalOffset: dropAnimationOffset) - } else if isReturningDrag { - dragCards(returningCards, additionalOffset: dragReturnOffset) + if drag.isDroppingCards { + dragCards(drag.droppingSelection?.cards ?? [], additionalOffset: drag.dropAnimationOffset) + } else if drag.isReturningDrag { + dragCards(drag.returningCards, additionalOffset: drag.dragReturnOffset) } else if viewModel.isDragging, let selection = viewModel.selection { dragCards(selection.cards, additionalOffset: .zero) } @@ -134,13 +129,29 @@ struct DragOverlayView: View { .accessibilityElement(children: .ignore) } + /// A waste card returning from an invalid drop flies back to the fan slot + /// it left, not to wherever the fan has since collapsed to — the anchor + /// frame captured at pickup overrides the card's live frame. + private var effectiveCardFrames: [UUID: CGRect] { + guard drag.isReturningDrag, + let returningCard = drag.returningCards.first, + returningCard.id == drag.wasteReturnAnchorCardID, + let anchorFrame = drag.wasteReturnAnchorFrame else { + return cardFrames + } + var frames = cardFrames + frames[returningCard.id] = anchorFrame + return frames + } + @ViewBuilder private func dragCards(_ cards: [Card], additionalOffset: CGSize) -> some View { if cards.isEmpty { EmptyView() } else { + let frames = effectiveCardFrames ForEach(cards, id: \.id) { card in - if let frame = cardFrames[card.id] { + if let frame = frames[card.id] { CardView( card: card, isSelected: true, @@ -149,11 +160,11 @@ struct DragOverlayView: View { cardTilts: .constant([:]), isAccessibilityElement: false ) - .rotationEffect(.degrees(overlayTilt)) + .rotationEffect(.degrees(drag.overlayTilt)) .position(x: frame.midX, y: frame.midY) .offset( - x: dragTranslation.width + additionalOffset.width, - y: dragTranslation.height + additionalOffset.height + x: drag.dragTranslation.width + additionalOffset.width, + y: drag.dragTranslation.height + additionalOffset.height ) .shadow(color: Color.black.opacity(0.3), radius: 6, x: 0, y: 4) } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 8873b7f..22c8dd3 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -86,20 +86,9 @@ struct ContentView: View { @State private var viewModel = SolitaireViewModel() @State private var hapticFeedback = HapticManager.shared @State private var dropFrames: [DropTarget: DropTargetGeometry] = [:] - @State private var activeTarget: DropTarget? - @State private var dragTranslation: CGSize = .zero - @State private var dragReturnOffset: CGSize = .zero - @State private var isReturningDrag = false - @State private var returningCards: [Card] = [] - @State private var isDroppingCards = false - @State private var droppingSelection: Selection? - @State private var dropAnimationOffset: CGSize = .zero - @State private var pendingDropDestination: Destination? + @State private var drag = DragInteractionController() @State private var cardFrames: [UUID: CGRect] = [:] - @State private var wasteReturnAnchorCardID: UUID? - @State private var wasteReturnAnchorFrame: CGRect? @State private var cardTilts: [UUID: Double] = [:] - @State private var overlayTilt: Double = 0 #if os(iOS) @State private var isShowingSettings = false #else @@ -496,11 +485,11 @@ struct ContentView: View { processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() } - .onChange(of: isDroppingCards) { _, _ in + .onChange(of: drag.isDroppingCards) { _, _ in processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() } - .onChange(of: isReturningDrag) { _, _ in + .onChange(of: drag.isReturningDrag) { _, _ in processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() } @@ -595,7 +584,7 @@ struct ContentView: View { cardSize: cardSize, columnSpacing: metrics.columnSpacing, wasteFanSpacing: metrics.wasteFanSpacing, - activeTarget: activeTarget, + activeTarget: drag.activeTarget, hintedTarget: hintedTarget, isStockHinted: viewModel.isStockHinted, isWasteHinted: viewModel.isWasteHinted, @@ -622,7 +611,7 @@ struct ContentView: View { cardSize: cardSize, columnSpacing: metrics.columnSpacing, maxBoardHeight: metrics.tableauMaxHeight, - activeTarget: activeTarget, + activeTarget: drag.activeTarget, hintedTarget: hintedTarget, hintHighlightOpacity: hintHighlightOpacity, isCardTiltEnabled: isCardTiltEnabled, @@ -657,7 +646,7 @@ struct ContentView: View { faceDownOffset: metrics.tableauFaceDownOffset, faceUpOffset: metrics.tableauFaceUpOffset, maxPileHeight: metrics.tableauMaxHeight, - activeTarget: activeTarget, + activeTarget: drag.activeTarget, hintedTarget: hintedTarget, hintHighlightOpacity: hintHighlightOpacity, isCardTiltEnabled: isCardTiltEnabled, @@ -679,7 +668,7 @@ struct ContentView: View { faceDownOffset: metrics.tableauFaceDownOffset, faceUpOffset: metrics.tableauFaceUpOffset, maxPileHeight: metrics.tableauMaxHeight, - activeTarget: activeTarget, + activeTarget: drag.activeTarget, hintedTarget: hintedTarget, hintHighlightOpacity: hintHighlightOpacity, isCardTiltEnabled: isCardTiltEnabled, @@ -813,7 +802,7 @@ struct ContentView: View { guard !dealingCardIDs.isEmpty || !dealAnimationCards.isEmpty else { return } cancelDealAnimation() } - .animation(.easeInOut(duration: 0.12), value: activeTarget) + .animation(.easeInOut(duration: 0.12), value: drag.activeTarget) .overlay { GeometryReader { _ in ZStack { @@ -840,16 +829,8 @@ struct ContentView: View { .zIndex(90) DragOverlayView( viewModel: viewModel, - cardFrames: dragOverlayCardFrames, - cardTilts: cardTilts, - dragTranslation: dragTranslation, - dragReturnOffset: dragReturnOffset, - isReturningDrag: isReturningDrag, - returningCards: returningCards, - isDroppingCards: isDroppingCards, - droppingCards: droppingSelection?.cards ?? [], - dropAnimationOffset: dropAnimationOffset, - overlayTilt: overlayTilt + drag: drag, + cardFrames: cardFrames ) .zIndex(100) } @@ -945,8 +926,8 @@ struct ContentView: View { private var isUndoDisabled: Bool { !viewModel.canUndo || isUndoAnimating - || isDroppingCards - || isReturningDrag + || drag.isDroppingCards + || drag.isReturningDrag || viewModel.isDragging || isWinCascadeAnimating } @@ -954,8 +935,8 @@ struct ContentView: View { private var isAutoFinishDisabled: Bool { !viewModel.isAutoFinishAvailable || isUndoAnimating - || isDroppingCards - || isReturningDrag + || drag.isDroppingCards + || drag.isReturningDrag || viewModel.isDragging || viewModel.pendingAutoMove != nil || isWinCascadeAnimating @@ -964,8 +945,8 @@ struct ContentView: View { private var isHintDisabled: Bool { viewModel.isWin || isUndoAnimating - || isDroppingCards - || isReturningDrag + || drag.isDroppingCards + || drag.isReturningDrag || viewModel.isDragging || viewModel.pendingAutoMove != nil || !viewModel.isHintAvailable @@ -1081,17 +1062,7 @@ struct ContentView: View { /// Clears in-flight drag/drop/undo/draw animation state so stale animation /// completions cannot mutate the game that replaces the current one. private func resetTransientBoardState() { - activeTarget = nil - dragTranslation = .zero - dragReturnOffset = .zero - isReturningDrag = false - returningCards = [] - isDroppingCards = false - droppingSelection = nil - dropAnimationOffset = .zero - pendingDropDestination = nil - wasteReturnAnchorCardID = nil - wasteReturnAnchorFrame = nil + drag.reset() drawAnimationCards = [] drawingCardIDs = [] drawAnimationToken = UUID() @@ -1127,7 +1098,7 @@ struct ContentView: View { stopAutoFinish() return } - guard !isDroppingCards, !isReturningDrag, !isUndoAnimating else { return } + guard !drag.isDroppingCards, !drag.isReturningDrag, !isUndoAnimating else { return } guard !viewModel.isDragging else { return } guard viewModel.pendingAutoMove == nil else { return } @@ -1137,28 +1108,28 @@ struct ContentView: View { } private func handleEscape() { - guard viewModel.isDragging, !isReturningDrag, !isDroppingCards else { return } - activeTarget = nil + guard viewModel.isDragging, !drag.isReturningDrag, !drag.isDroppingCards else { return } + drag.setActiveTarget(nil) beginReturnAnimation() } private func processPendingAutoMoveIfPossible() { guard let request = viewModel.pendingAutoMove else { return } - guard !isDroppingCards, !isReturningDrag, !isUndoAnimating else { return } + guard !drag.isDroppingCards, !drag.isReturningDrag, !isUndoAnimating else { return } guard !viewModel.isDragging else { return } viewModel.clearPendingAutoMove() - dragTranslation = .zero - dragReturnOffset = .zero - activeTarget = nil + drag.dragTranslation = .zero + drag.dragReturnOffset = .zero + drag.setActiveTarget(nil) viewModel.selection = request.selection viewModel.isDragging = true if let firstCard = request.selection.cards.first { - overlayTilt = cardTilts[firstCard.id] ?? 0 + drag.overlayTilt = cardTilts[firstCard.id] ?? 0 let tiltSettleDuration = isAutoFinishing ? 0.1 : 0.15 withAnimation(.easeOut(duration: tiltSettleDuration)) { - overlayTilt = 0 + drag.overlayTilt = 0 } } @@ -1175,8 +1146,8 @@ struct ContentView: View { let started = startDrag(from: origin) if !started { return } } - dragTranslation = value.translation - activeTarget = dropTarget(at: value.location) + drag.dragTranslation = value.translation + drag.setActiveTarget(dropTarget(at: value.location)) } .onEnded { _ in finishDrag() @@ -1186,11 +1157,11 @@ struct ContentView: View { private func startDrag(from origin: DragOrigin) -> Bool { stopAutoFinish() - wasteReturnAnchorCardID = nil - wasteReturnAnchorFrame = nil - dragTranslation = .zero - dragReturnOffset = .zero - isReturningDrag = false + drag.wasteReturnAnchorCardID = nil + drag.wasteReturnAnchorFrame = nil + drag.dragTranslation = .zero + drag.dragReturnOffset = .zero + drag.isReturningDrag = false let started: Bool switch origin { case .waste: @@ -1211,14 +1182,14 @@ struct ContentView: View { if started, let firstCard = viewModel.selection?.cards.first { if case .waste = origin { - wasteReturnAnchorCardID = firstCard.id - wasteReturnAnchorFrame = cardFrames[firstCard.id] + drag.wasteReturnAnchorCardID = firstCard.id + drag.wasteReturnAnchorFrame = cardFrames[firstCard.id] } HapticManager.shared.play(.cardPickUp) // Start with the card's current tilt, then animate to straight - overlayTilt = cardTilts[firstCard.id] ?? 0 + drag.overlayTilt = cardTilts[firstCard.id] ?? 0 withAnimation(.easeOut(duration: 0.15)) { - overlayTilt = 0 + drag.overlayTilt = 0 } } return started @@ -1226,13 +1197,13 @@ struct ContentView: View { private func finishDrag() { guard viewModel.isDragging else { - dragTranslation = .zero - activeTarget = nil + drag.dragTranslation = .zero + drag.setActiveTarget(nil) return } - let target = activeTarget - activeTarget = nil + let target = drag.activeTarget + drag.setActiveTarget(nil) if let target { let dest = destination(for: target) if viewModel.canDrop(to: dest) { @@ -1251,22 +1222,22 @@ struct ContentView: View { let cardFrame = cardFrames[firstCard.id], let targetFrame = dropFrames[target]?.snapFrame else { viewModel.handleDrop(to: dest) - wasteReturnAnchorCardID = nil - wasteReturnAnchorFrame = nil - dragTranslation = .zero + drag.wasteReturnAnchorCardID = nil + drag.wasteReturnAnchorFrame = nil + drag.dragTranslation = .zero return } // Calculate offset from current dragged position to destination - let currentX = cardFrame.midX + dragTranslation.width - let currentY = cardFrame.midY + dragTranslation.height + let currentX = cardFrame.midX + drag.dragTranslation.width + let currentY = cardFrame.midY + drag.dragTranslation.height let targetX = targetFrame.midX let targetY = targetFrame.midY - droppingSelection = selection - pendingDropDestination = dest - isDroppingCards = true - dropAnimationOffset = .zero + drag.droppingSelection = selection + drag.pendingDropDestination = dest + drag.isDroppingCards = true + drag.dropAnimationOffset = .zero // Keep viewModel.isDragging true to hide original card during animation let offsetToTarget = CGSize( @@ -1276,12 +1247,12 @@ struct ContentView: View { let dropDuration = isAutoFinishing ? 0.18 : 0.25 withAnimation(.spring(response: dropDuration, dampingFraction: 0.85)) { - dropAnimationOffset = offsetToTarget + drag.dropAnimationOffset = offsetToTarget } DispatchQueue.main.asyncAfter(deadline: .now() + dropDuration) { // Clear old tilts so cards get fresh tilts at new position - if let cards = droppingSelection?.cards { + if let cards = drag.droppingSelection?.cards { for card in cards { cardTilts.removeValue(forKey: card.id) } @@ -1291,17 +1262,17 @@ struct ContentView: View { var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { - if let dest = pendingDropDestination { + if let dest = drag.pendingDropDestination { viewModel.handleDrop(to: dest) } - dragTranslation = .zero - dropAnimationOffset = .zero - isDroppingCards = false - droppingSelection = nil - pendingDropDestination = nil + drag.dragTranslation = .zero + drag.dropAnimationOffset = .zero + drag.isDroppingCards = false + drag.droppingSelection = nil + drag.pendingDropDestination = nil } - wasteReturnAnchorCardID = nil - wasteReturnAnchorFrame = nil + drag.wasteReturnAnchorCardID = nil + drag.wasteReturnAnchorFrame = nil if !isAutoFinishing { DispatchQueue.main.async { viewModel.refreshAutoFinishAvailability() @@ -1312,14 +1283,14 @@ struct ContentView: View { } private func beginReturnAnimation() { - guard !isReturningDrag else { return } + guard !drag.isReturningDrag else { return } SoundManager.shared.play(.invalidDrop) HapticManager.shared.play(.invalidDrop) let isWasteReturn = viewModel.selection?.source == .waste - let currentTranslation = dragTranslation - returningCards = viewModel.selection?.cards ?? [] + let currentTranslation = drag.dragTranslation + drag.returningCards = viewModel.selection?.cards ?? [] let targetTilt: Double = { - guard let firstCard = returningCards.first else { return 0 } + guard let firstCard = drag.returningCards.first else { return 0 } guard isWasteReturn, isCardTiltEnabled else { return cardTilts[firstCard.id] ?? 0 } @@ -1328,37 +1299,25 @@ struct ContentView: View { return rerolledTilt }() // Keep viewModel.isDragging true to hide original card during animation - isReturningDrag = true - dragReturnOffset = .zero + drag.isReturningDrag = true + drag.dragReturnOffset = .zero withAnimation(.spring(response: 0.32, dampingFraction: 0.9)) { - dragReturnOffset = CGSize(width: -currentTranslation.width, height: -currentTranslation.height) - overlayTilt = targetTilt + drag.dragReturnOffset = CGSize(width: -currentTranslation.width, height: -currentTranslation.height) + drag.overlayTilt = targetTilt } let returnDuration = 0.32 DispatchQueue.main.asyncAfter(deadline: .now() + returnDuration) { viewModel.cancelDrag() - wasteReturnAnchorCardID = nil - wasteReturnAnchorFrame = nil - dragTranslation = .zero - dragReturnOffset = .zero - isReturningDrag = false - returningCards = [] + drag.wasteReturnAnchorCardID = nil + drag.wasteReturnAnchorFrame = nil + drag.dragTranslation = .zero + drag.dragReturnOffset = .zero + drag.isReturningDrag = false + drag.returningCards = [] processPendingAutoMoveIfPossible() } } - private var dragOverlayCardFrames: [UUID: CGRect] { - guard isReturningDrag, - let returningCard = returningCards.first, - returningCard.id == wasteReturnAnchorCardID, - let anchorFrame = wasteReturnAnchorFrame else { - return cardFrames - } - var frames = cardFrames - frames[returningCard.id] = anchorFrame - return frames - } - private func syncFanProgress(with waste: [Card], excluding excluded: Set) { let ids = Set(waste.map(\.id)) wasteFanProgress = wasteFanProgress.filter { ids.contains($0.key) } @@ -1503,7 +1462,7 @@ struct ContentView: View { private func beginUndoAnimationIfNeeded() { guard !isUndoAnimating else { return } - guard !viewModel.isDragging, !isDroppingCards, !isReturningDrag else { return } + guard !viewModel.isDragging, !drag.isDroppingCards, !drag.isReturningDrag else { return } guard !viewModel.isWin else { return } guard let snapshot = viewModel.peekUndoSnapshot() else { return } HapticManager.shared.play(.undoMove) From a179aa4a167e34c4c132d8b0325d8b38a2d39f1e Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 20:18:54 -0700 Subject: [PATCH 3/3] confine win cascade ticks to the cascade overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the celebration task mutates its cards array every frame while the cascade flies, and contentview's body read it — re-evaluating the whole board per tick. the overlay reads the controller itself now, so the ticks re-render only the flying cards. --- ComputerSolitaire/Views/Shared/BoardOverlayViews.swift | 7 +++++-- ComputerSolitaire/Views/Shared/ContentView.swift | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift index 6667945..40a9477 100644 --- a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift @@ -48,10 +48,13 @@ struct UndoOverlayView: View { } struct WinCascadeOverlayView: View { - let cards: [WinCascadeCardState] + /// The cascade task mutates `cards` every frame while cards fly; reading + /// it here — not in ContentView — keeps the per-tick re-render confined + /// to this overlay. + let winCelebration: WinCelebrationController var body: some View { - ForEach(cards) { item in + ForEach(winCelebration.cards) { item in let isVisible = item.elapsed >= item.activationDelay CardView( card: item.card, diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 22c8dd3..a72c43f 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -825,7 +825,7 @@ struct ContentView: View { progress: undoAnimationProgress ) .zIndex(75) - WinCascadeOverlayView(cards: winCelebration.cards) + WinCascadeOverlayView(winCelebration: winCelebration) .zIndex(90) DragOverlayView( viewModel: viewModel,