From 6d26faba1eb32368e1d824475ce056ffad6dc95a Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Wed, 15 Jul 2026 13:05:05 -0700 Subject: [PATCH] restore card flight animations lost in the drag controller extraction --- .../DragInteractionController.swift | 40 ++---- .../Views/Shared/BoardOverlayViews.swift | 35 +++-- .../Views/Shared/ContentView.swift | 135 +++++++++++------- 3 files changed, 119 insertions(+), 91 deletions(-) diff --git a/ComputerSolitaire/Interaction/DragInteractionController.swift b/ComputerSolitaire/Interaction/DragInteractionController.swift index cb364e9..f848324 100644 --- a/ComputerSolitaire/Interaction/DragInteractionController.swift +++ b/ComputerSolitaire/Interaction/DragInteractionController.swift @@ -2,28 +2,22 @@ import CoreGraphics import Foundation import Observation -/// The drag gesture's fast-changing state, extracted from ContentView so that +/// The drag gesture's per-frame 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. +/// and the drop-highlight rows — instead of the whole board tree. +/// +/// Only state written on every gesture frame lives here. The flight-boundary +/// state (drop/return offsets, the overlay tilt) stays as `@State` on +/// ContentView: those fields are written with `withAnimation`, and an +/// `@Observable` property loses that transaction when another property has +/// already invalidated the reader in the same tick — the flight would render +/// straight at its destination. `@State` animates per attribute, so the +/// spring survives the surrounding unanimated writes. @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? @@ -37,20 +31,10 @@ final class DragInteractionController { } /// 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()`. + /// stale in-flight drag behind. The flight-boundary fields are cleared by + /// ContentView's `resetTransientBoardState()` alongside this call. 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 40a9477..ffe3cbb 100644 --- a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift @@ -111,18 +111,29 @@ 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. + /// The gesture's per-frame translation. Read here — and only here — so the + /// per-frame writes re-render just this overlay, never the board tree + /// behind it. The flight-boundary fields arrive as plain values from + /// ContentView's `@State` so their `withAnimation` springs survive (see + /// DragInteractionController's doc comment). let drag: DragInteractionController let cardFrames: [UUID: CGRect] + let overlayTilt: Double + let dragReturnOffset: CGSize + let isReturningDrag: Bool + let returningCards: [Card] + let isDroppingCards: Bool + let droppingCards: [Card] + let dropAnimationOffset: CGSize + let wasteReturnAnchorCardID: UUID? + let wasteReturnAnchorFrame: CGRect? var body: some View { Group { - if drag.isDroppingCards { - dragCards(drag.droppingSelection?.cards ?? [], additionalOffset: drag.dropAnimationOffset) - } else if drag.isReturningDrag { - dragCards(drag.returningCards, additionalOffset: drag.dragReturnOffset) + if isDroppingCards { + dragCards(droppingCards, additionalOffset: dropAnimationOffset) + } else if isReturningDrag { + dragCards(returningCards, additionalOffset: dragReturnOffset) } else if viewModel.isDragging, let selection = viewModel.selection { dragCards(selection.cards, additionalOffset: .zero) } @@ -136,10 +147,10 @@ struct DragOverlayView: View { /// 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 { + guard isReturningDrag, + let returningCard = returningCards.first, + returningCard.id == wasteReturnAnchorCardID, + let anchorFrame = wasteReturnAnchorFrame else { return cardFrames } var frames = cardFrames @@ -163,7 +174,7 @@ struct DragOverlayView: View { cardTilts: .constant([:]), isAccessibilityElement: false ) - .rotationEffect(.degrees(drag.overlayTilt)) + .rotationEffect(.degrees(overlayTilt)) .position(x: frame.midX, y: frame.midY) .offset( x: drag.dragTranslation.width + additionalOffset.width, diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 90f6305..0d6a142 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -87,6 +87,20 @@ struct ContentView: View { @State private var hapticFeedback = HapticManager.shared @State private var dropFrames: [DropTarget: DropTargetGeometry] = [:] @State private var drag = DragInteractionController() + // Flight-boundary drag state stays as `@State` — not on the controller — + // because these fields are written with `withAnimation`, and `@State` + // preserves that transaction per attribute even when unanimated writes + // land in the same tick. See DragInteractionController's doc comment. + @State private var overlayTilt: Double = 0 + @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 wasteReturnAnchorCardID: UUID? + @State private var wasteReturnAnchorFrame: CGRect? @State private var cardFrames: [UUID: CGRect] = [:] @State private var cardTilts: [UUID: Double] = [:] #if os(iOS) @@ -485,11 +499,11 @@ struct ContentView: View { processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() } - .onChange(of: drag.isDroppingCards) { _, _ in + .onChange(of: isDroppingCards) { _, _ in processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() } - .onChange(of: drag.isReturningDrag) { _, _ in + .onChange(of: isReturningDrag) { _, _ in processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() } @@ -846,7 +860,16 @@ struct ContentView: View { DragOverlayView( viewModel: viewModel, drag: drag, - cardFrames: cardFrames + cardFrames: cardFrames, + overlayTilt: overlayTilt, + dragReturnOffset: dragReturnOffset, + isReturningDrag: isReturningDrag, + returningCards: returningCards, + isDroppingCards: isDroppingCards, + droppingCards: droppingSelection?.cards ?? [], + dropAnimationOffset: dropAnimationOffset, + wasteReturnAnchorCardID: wasteReturnAnchorCardID, + wasteReturnAnchorFrame: wasteReturnAnchorFrame ) .zIndex(100) } @@ -942,8 +965,8 @@ struct ContentView: View { private var isUndoDisabled: Bool { !viewModel.canUndo || isUndoAnimating - || drag.isDroppingCards - || drag.isReturningDrag + || isDroppingCards + || isReturningDrag || viewModel.isDragging || isWinCascadeAnimating } @@ -951,8 +974,8 @@ struct ContentView: View { private var isAutoFinishDisabled: Bool { !viewModel.isAutoFinishAvailable || isUndoAnimating - || drag.isDroppingCards - || drag.isReturningDrag + || isDroppingCards + || isReturningDrag || viewModel.isDragging || viewModel.pendingAutoMove != nil || isWinCascadeAnimating @@ -961,8 +984,8 @@ struct ContentView: View { private var isHintDisabled: Bool { viewModel.isWin || isUndoAnimating - || drag.isDroppingCards - || drag.isReturningDrag + || isDroppingCards + || isReturningDrag || viewModel.isDragging || viewModel.pendingAutoMove != nil || !viewModel.isHintAvailable @@ -1079,6 +1102,16 @@ struct ContentView: View { /// completions cannot mutate the game that replaces the current one. private func resetTransientBoardState() { drag.reset() + overlayTilt = 0 + dragReturnOffset = .zero + isReturningDrag = false + returningCards = [] + isDroppingCards = false + droppingSelection = nil + dropAnimationOffset = .zero + pendingDropDestination = nil + wasteReturnAnchorCardID = nil + wasteReturnAnchorFrame = nil drawAnimationCards = [] drawingCardIDs = [] drawAnimationToken = UUID() @@ -1114,7 +1147,7 @@ struct ContentView: View { stopAutoFinish() return } - guard !drag.isDroppingCards, !drag.isReturningDrag, !isUndoAnimating else { return } + guard !isDroppingCards, !isReturningDrag, !isUndoAnimating else { return } guard !viewModel.isDragging else { return } guard viewModel.pendingAutoMove == nil else { return } @@ -1124,28 +1157,28 @@ struct ContentView: View { } private func handleEscape() { - guard viewModel.isDragging, !drag.isReturningDrag, !drag.isDroppingCards else { return } + guard viewModel.isDragging, !isReturningDrag, !isDroppingCards else { return } drag.setActiveTarget(nil) beginReturnAnimation() } private func processPendingAutoMoveIfPossible() { guard let request = viewModel.pendingAutoMove else { return } - guard !drag.isDroppingCards, !drag.isReturningDrag, !isUndoAnimating else { return } + guard !isDroppingCards, !isReturningDrag, !isUndoAnimating else { return } guard !viewModel.isDragging else { return } viewModel.clearPendingAutoMove() drag.dragTranslation = .zero - drag.dragReturnOffset = .zero + dragReturnOffset = .zero drag.setActiveTarget(nil) viewModel.selection = request.selection viewModel.isDragging = true if let firstCard = request.selection.cards.first { - drag.overlayTilt = cardTilts[firstCard.id] ?? 0 + overlayTilt = cardTilts[firstCard.id] ?? 0 let tiltSettleDuration = isAutoFinishing ? 0.1 : 0.15 withAnimation(.easeOut(duration: tiltSettleDuration)) { - drag.overlayTilt = 0 + overlayTilt = 0 } } @@ -1173,11 +1206,11 @@ struct ContentView: View { private func startDrag(from origin: DragOrigin) -> Bool { stopAutoFinish() - drag.wasteReturnAnchorCardID = nil - drag.wasteReturnAnchorFrame = nil + wasteReturnAnchorCardID = nil + wasteReturnAnchorFrame = nil drag.dragTranslation = .zero - drag.dragReturnOffset = .zero - drag.isReturningDrag = false + dragReturnOffset = .zero + isReturningDrag = false let started: Bool switch origin { case .waste: @@ -1198,14 +1231,14 @@ struct ContentView: View { if started, let firstCard = viewModel.selection?.cards.first { if case .waste = origin { - drag.wasteReturnAnchorCardID = firstCard.id - drag.wasteReturnAnchorFrame = cardFrames[firstCard.id] + wasteReturnAnchorCardID = firstCard.id + wasteReturnAnchorFrame = cardFrames[firstCard.id] } HapticManager.shared.play(.cardPickUp) // Start with the card's current tilt, then animate to straight - drag.overlayTilt = cardTilts[firstCard.id] ?? 0 + overlayTilt = cardTilts[firstCard.id] ?? 0 withAnimation(.easeOut(duration: 0.15)) { - drag.overlayTilt = 0 + overlayTilt = 0 } } return started @@ -1238,8 +1271,8 @@ struct ContentView: View { let cardFrame = cardFrames[firstCard.id], let targetFrame = dropFrames[target]?.snapFrame else { viewModel.handleDrop(to: dest) - drag.wasteReturnAnchorCardID = nil - drag.wasteReturnAnchorFrame = nil + wasteReturnAnchorCardID = nil + wasteReturnAnchorFrame = nil drag.dragTranslation = .zero return } @@ -1250,10 +1283,10 @@ struct ContentView: View { let targetX = targetFrame.midX let targetY = targetFrame.midY - drag.droppingSelection = selection - drag.pendingDropDestination = dest - drag.isDroppingCards = true - drag.dropAnimationOffset = .zero + droppingSelection = selection + pendingDropDestination = dest + isDroppingCards = true + dropAnimationOffset = .zero // Keep viewModel.isDragging true to hide original card during animation let offsetToTarget = CGSize( @@ -1263,12 +1296,12 @@ struct ContentView: View { let dropDuration = isAutoFinishing ? 0.18 : 0.25 withAnimation(.spring(response: dropDuration, dampingFraction: 0.85)) { - drag.dropAnimationOffset = offsetToTarget + dropAnimationOffset = offsetToTarget } DispatchQueue.main.asyncAfter(deadline: .now() + dropDuration) { // Clear old tilts so cards get fresh tilts at new position - if let cards = drag.droppingSelection?.cards { + if let cards = droppingSelection?.cards { for card in cards { cardTilts.removeValue(forKey: card.id) } @@ -1278,17 +1311,17 @@ struct ContentView: View { var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { - if let dest = drag.pendingDropDestination { + if let dest = pendingDropDestination { viewModel.handleDrop(to: dest) } drag.dragTranslation = .zero - drag.dropAnimationOffset = .zero - drag.isDroppingCards = false - drag.droppingSelection = nil - drag.pendingDropDestination = nil + dropAnimationOffset = .zero + isDroppingCards = false + droppingSelection = nil + pendingDropDestination = nil } - drag.wasteReturnAnchorCardID = nil - drag.wasteReturnAnchorFrame = nil + wasteReturnAnchorCardID = nil + wasteReturnAnchorFrame = nil if !isAutoFinishing { DispatchQueue.main.async { viewModel.refreshAutoFinishAvailability() @@ -1299,14 +1332,14 @@ struct ContentView: View { } private func beginReturnAnimation() { - guard !drag.isReturningDrag else { return } + guard !isReturningDrag else { return } SoundManager.shared.play(.invalidDrop) HapticManager.shared.play(.invalidDrop) let isWasteReturn = viewModel.selection?.source == .waste let currentTranslation = drag.dragTranslation - drag.returningCards = viewModel.selection?.cards ?? [] + returningCards = viewModel.selection?.cards ?? [] let targetTilt: Double = { - guard let firstCard = drag.returningCards.first else { return 0 } + guard let firstCard = returningCards.first else { return 0 } guard isWasteReturn, isCardTiltEnabled else { return cardTilts[firstCard.id] ?? 0 } @@ -1315,21 +1348,21 @@ struct ContentView: View { return rerolledTilt }() // Keep viewModel.isDragging true to hide original card during animation - drag.isReturningDrag = true - drag.dragReturnOffset = .zero + isReturningDrag = true + dragReturnOffset = .zero withAnimation(.spring(response: 0.32, dampingFraction: 0.9)) { - drag.dragReturnOffset = CGSize(width: -currentTranslation.width, height: -currentTranslation.height) - drag.overlayTilt = targetTilt + dragReturnOffset = CGSize(width: -currentTranslation.width, height: -currentTranslation.height) + overlayTilt = targetTilt } let returnDuration = 0.32 DispatchQueue.main.asyncAfter(deadline: .now() + returnDuration) { viewModel.cancelDrag() - drag.wasteReturnAnchorCardID = nil - drag.wasteReturnAnchorFrame = nil + wasteReturnAnchorCardID = nil + wasteReturnAnchorFrame = nil drag.dragTranslation = .zero - drag.dragReturnOffset = .zero - drag.isReturningDrag = false - drag.returningCards = [] + dragReturnOffset = .zero + isReturningDrag = false + returningCards = [] processPendingAutoMoveIfPossible() } } @@ -1478,7 +1511,7 @@ struct ContentView: View { private func beginUndoAnimationIfNeeded() { guard !isUndoAnimating else { return } - guard !viewModel.isDragging, !drag.isDroppingCards, !drag.isReturningDrag else { return } + guard !viewModel.isDragging, !isDroppingCards, !isReturningDrag else { return } guard !viewModel.isWin else { return } guard let snapshot = viewModel.peekUndoSnapshot() else { return } HapticManager.shared.play(.undoMove)