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

grain.social/grain-ios

1 collaborator
0
1
main
175 lines · 6.5 KB Raw History
import SwiftUI

// MARK: - Layout value key

/// Each cell tags itself with its array index so the Layout knows
/// which subview to place at which position.
struct PhotoIndexKey: LayoutValueKey {
    static let defaultValue: Int = 0
}

// MARK: - Adaptive photo layout

/// Single Layout that places photo cells in three modes: horizontal strip,
/// 3-column grid, or vertical captions list. When `mode` changes inside
/// `withAnimation`, SwiftUI interpolates each subview's position/size from
/// old to new — no matchedGeometryEffect, no conditional view swaps.
struct AdaptivePhotoLayout: Layout {
    var mode: EditorMode
    var containerWidth: CGFloat
    var stripScrollOffset: CGFloat = 0
    var dragPlacement: ReorderDragPlacement?

    // Strip — values must match StripScrollState.thumbSize / .spacing
    private let stripThumbSize: CGFloat = 72
    private let stripSpacing: CGFloat = 20
    private let stripVerticalPadding: CGFloat = 22

    // Grid
    private let gridColumnCount = 3
    private let gridSpacing: CGFloat = 4
    private let gridOuterPadding: CGFloat = 16
    private let gridTopPadding: CGFloat = 22
    private let gridBottomPadding: CGFloat = 12

    // Captions
    private let captionsHorizontalPadding: CGFloat = 16
    private let captionsVerticalPadding: CGFloat = 10

    private var gridCellSide: CGFloat {
        let total = max(0, containerWidth - gridOuterPadding * 2 - gridSpacing * CGFloat(gridColumnCount - 1))
        return max(1, total / CGFloat(gridColumnCount))
    }

    private var gridStride: CGFloat {
        gridCellSide + gridSpacing
    }

    // MARK: - Layout protocol

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache _: inout ()
    ) -> CGSize {
        let width = proposal.width ?? containerWidth
        guard !subviews.isEmpty else { return CGSize(width: width, height: 0) }

        switch mode {
        case .preview:
            return CGSize(width: width, height: stripThumbSize + stripVerticalPadding * 2)

        case .reorder:
            let rows = Int(ceil(Double(subviews.count) / Double(gridColumnCount)))
            let height = CGFloat(rows) * gridCellSide
                + CGFloat(max(0, rows - 1)) * gridSpacing
                + gridTopPadding + gridBottomPadding
            return CGSize(width: width, height: height)

        case .captions:
            var height: CGFloat = 0
            let rowWidth = max(0, width - captionsHorizontalPadding * 2)
            let rowProposal = ProposedViewSize(width: rowWidth, height: nil)
            for subview in subviews {
                let size = subview.sizeThatFits(rowProposal)
                height += size.height + captionsVerticalPadding * 2
            }
            return CGSize(width: width, height: height)
        }
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal _: ProposedViewSize,
        subviews: Subviews,
        cache _: inout ()
    ) {
        switch mode {
        case .preview: placeAsStrip(in: bounds, subviews: subviews)
        case .reorder: placeAsGrid(in: bounds, subviews: subviews)
        case .captions: placeAsCaptions(in: bounds, subviews: subviews)
        }
    }

    // MARK: - Strip placement

    private func placeAsStrip(in bounds: CGRect, subviews: Subviews) {
        let proposal = ProposedViewSize(width: stripThumbSize, height: stripThumbSize)
        let centerY = bounds.midY
        for subview in subviews {
            let index = subview[PhotoIndexKey.self]
            let x = bounds.minX + stripScrollOffset
                + CGFloat(index) * (stripThumbSize + stripSpacing)
                + stripThumbSize / 2
            subview.place(at: CGPoint(x: x, y: centerY), anchor: .center, proposal: proposal)
        }
    }

    // MARK: - Grid placement

    private func placeAsGrid(in bounds: CGRect, subviews: Subviews) {
        let proposal = ProposedViewSize(width: gridCellSide, height: gridCellSide)
        for subview in subviews {
            let index = subview[PhotoIndexKey.self]
            let col = index % gridColumnCount
            let row = index / gridColumnCount

            var offsetX: CGFloat = 0
            var offsetY: CGFloat = 0

            if let drag = dragPlacement {
                if index == drag.draggedIndex {
                    // Dragged cell stays at its natural slot — the view-level
                    // .offset modifier positions it at the finger in real time.
                    // Keeping dragOffset out of the Layout ensures the sibling
                    // spring animation transaction is never contaminated by the
                    // immediate dragOffset update.
                } else {
                    let slotDelta: Int = if drag.currentIndex > drag.draggedIndex,
                                            index > drag.draggedIndex, index <= drag.currentIndex
                    {
                        -1
                    } else if drag.currentIndex < drag.draggedIndex,
                              index >= drag.currentIndex, index < drag.draggedIndex
                    {
                        1
                    } else {
                        0
                    }

                    if slotDelta != 0 {
                        let newIdx = index + slotDelta
                        let newRow = newIdx / gridColumnCount
                        let newCol = newIdx % gridColumnCount
                        offsetX = CGFloat(newCol - col) * gridStride
                        offsetY = CGFloat(newRow - row) * gridStride
                    }
                }
            }

            let x = bounds.minX + gridOuterPadding
                + CGFloat(col) * gridStride + gridCellSide / 2 + offsetX
            let y = bounds.minY + gridTopPadding
                + CGFloat(row) * gridStride + gridCellSide / 2 + offsetY
            subview.place(at: CGPoint(x: x, y: y), anchor: .center, proposal: proposal)
        }
    }

    // MARK: - Captions placement

    private func placeAsCaptions(in bounds: CGRect, subviews: Subviews) {
        var y = bounds.minY
        let rowWidth = max(0, bounds.width - captionsHorizontalPadding * 2)
        let rowProposal = ProposedViewSize(width: rowWidth, height: nil)
        for subview in subviews {
            let size = subview.sizeThatFits(rowProposal)
            y += captionsVerticalPadding
            subview.place(
                at: CGPoint(x: bounds.minX + captionsHorizontalPadding, y: y),
                anchor: .topLeading,
                proposal: rowProposal
            )
            y += size.height + captionsVerticalPadding
        }
    }
}