Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 12 additions & 28 deletions ComputerSolitaire/Interaction/DragInteractionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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
}
}
35 changes: 23 additions & 12 deletions ComputerSolitaire/Views/Shared/BoardOverlayViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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,
Expand Down
Loading