~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
86 lines · 3.0 KB Raw History
import SwiftUI
import UIKit

// MARK: - Reorder drag state

/// Manages drag-to-reorder state for the photo grid. Extracted from
/// ReorderablePhotoGrid so the state is owned by GalleryEditor and the
/// AdaptivePhotoLayout can read it for cell displacement.
@Observable
@MainActor
final class ReorderDragState {
    var draggedID: UUID?
    var dragStartIndex: Int?
    var dragCurrentIndex: Int?
    var dragOffset: CGSize = .zero

    @ObservationIgnored var impactGenerator = UIImpactFeedbackGenerator(style: .medium)
    @ObservationIgnored var selectionGenerator = UISelectionFeedbackGenerator()

    var isDragging: Bool {
        draggedID != nil
    }

    func beginDrag(itemID: UUID, at index: Int) {
        guard draggedID == nil else { return }
        draggedID = itemID
        dragStartIndex = index
        dragCurrentIndex = index
        impactGenerator.prepare()
        selectionGenerator.prepare()
        impactGenerator.impactOccurred()
    }

    /// Updates `dragOffset` and returns the proposed slot index if it changed.
    /// The caller is responsible for wrapping `dragCurrentIndex` assignment in
    /// `withAnimation` — calling `withAnimation` from inside @Observable methods
    /// doesn't reliably propagate animation transactions to the Layout after the
    /// first update, causing subsequent slot changes to snap instead of animate.
    @discardableResult
    func handleDragChanged(
        translation: CGSize,
        itemCount: Int,
        columnCount: Int,
        stride: CGSize
    ) -> Int? {
        guard let start = dragStartIndex,
              stride.width > 0, stride.height > 0
        else { return nil }
        dragOffset = translation

        let colDelta = Int((translation.width / stride.width).rounded())
        let rowDelta = Int((translation.height / stride.height).rounded())

        let startRow = start / columnCount
        let startCol = start % columnCount
        let proposedRow = max(0, startRow + rowDelta)
        let proposedCol = max(0, min(columnCount - 1, startCol + colDelta))
        let rawProposed = proposedRow * columnCount + proposedCol
        let proposed = max(0, min(itemCount - 1, rawProposed))

        guard proposed != dragCurrentIndex else { return nil }
        return proposed
    }

    func reset() {
        draggedID = nil
        dragStartIndex = nil
        dragCurrentIndex = nil
        dragOffset = .zero
    }
}

// MARK: - Drag placement snapshot

/// Lightweight value snapshot of drag state for the Layout. Using a struct
/// (not the @Observable directly) keeps the Layout as pure value-type math.
///
/// `dragOffset` is intentionally excluded — it is applied as a view-level
/// `.offset` modifier on the dragged cell, not through the Layout. This keeps
/// the Layout parameter clean: it only changes when `currentIndex` changes
/// (inside `withAnimation`), so SwiftUI can animate sibling displacements
/// without the immediate dragOffset update contaminating the transaction.
struct ReorderDragPlacement: Equatable {
    let draggedIndex: Int
    let currentIndex: Int
}