Build the drop-slot model and the drop commits — drag & drop, first half

The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md
travels with it, rewritten for lanes, the interior masonry, multi-drag,
cross-board sessions, the re-grounding trio, and the committed-overlay hold):

- DropSlotMath — resting-layout zones from analytic lane arithmetic and the
  pure masonry placement (MasonryLayout now lays out through the same
  MasonryPlacement the drag reads, so geometry cannot drift), span-capped
  triggers sized to the dragged run's future footprint, hysteresis holds with
  the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold.
- DragAutoScrollMath — the activation bands and velocity ramp, pure.
- The drop commits, one performWrite bracket each: moveCards/copyCards within
  a board (insertion ranks touch only the dragged cards; renumber fallback);
  receiveCards/receiveLanes/receiveRestoredCards on the destination store for
  cross-board copy and ⌘-move with the import-boundary remint, lane copies
  stripping tombstoned cards while moves carry them; restoreByDrag is now
  positional, writing order only when the drop names a new one.

Gestures, sessions, previews, and delegates are the second half.

773 unit tests (87 new since the keyboard grammar).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 20:10:24 -04:00
parent 4035ba7986
commit 21a5a6dbfd
14 changed files with 2641 additions and 55 deletions
+125
View File
@@ -0,0 +1,125 @@
import CoreGraphics
/// Edge-autoscroll geometry for a scroll view hosting drop targets, as pure arithmetic no view,
/// no timer, no `NSScrollView` (`DragAutoScrollMathTests`). Ported from the pathfinder, whose
/// numbers are what was proven; the reasoning is reproduced because the behaviour is.
///
/// A lane's cards live in a scroll view, so a lane taller than its viewport has landing spots below
/// the fold and nothing in `DropSlotMath` can reach them, since the proposal is a function of the
/// cursor over the *visible* resting layout. A card session hovering near either end of a lane's
/// scroll area therefore scrolls it, continuously, until the pointer leaves the band or the drag
/// ends (DRAG-REORDER.md § Edge autoscroll).
///
/// ## The geometry
///
/// Along each axis the visible area owns an **activation band** of `band` points at either end. A
/// pointer inside a band scrolls that way at a speed that ramps with how deep into the band it
/// sits: `minSpeed` at the band's inner edge, `maxSpeed` at (and beyond) the visible area's own
/// edge. Outside both bands the velocity is exactly zero, so a drag that merely crosses the middle
/// of a lane never scrolls it.
///
/// The `minSpeed` floor is deliberate: entering a band produces immediate, visible motion instead
/// of an imperceptible crawl that leaves the user wondering whether autoscroll exists at all. It is
/// the one discontinuity in the ramp, and it sits exactly on the band boundary, where the pointer
/// is moving anyway.
///
/// The pointer may also sit *outside* the visible area and still drive it generously above and
/// below (the lane's header and the strip's padding are still "this lane"), but barely sideways, so
/// a drag over the neighbouring lane never scrolls this one. `engagementRect` is that reach; a
/// pointer outside it drives nothing.
///
/// Everything is axis-agnostic: the board strip has nothing to autoscroll today (every lane shares
/// the window width and the strip fills the window height 03-board-ui.md § Layout), and the same
/// math would serve one unchanged if that ever changes.
///
/// The ticking driver the physical-mouse read, the re-resolved proposal on every step, the
/// structurally terminated task is the drag session's, not this file's.
enum DragAutoScrollMath {
/// Thickness of the activation band at each end of the visible area.
static let band: CGFloat = 56
/// Speed at the band's inner edge the floor described above, in points/second.
static let minSpeed: CGFloat = 90
/// Speed at (and beyond) the visible area's own edge, in points/second. Deliberately not
/// faster: every scroll step re-resolves the drop proposal against the lane's resting grid, and
/// the distance the content travels between two resolutions is this speed divided by the tick
/// rate.
static let maxSpeed: CGFloat = 800
/// How far above the visible area the pointer may sit and still drive it enough to cover the
/// lane's header, which is where a drag naturally goes to scroll up.
static let reachAbove: CGFloat = 48
/// The same below, covering the lane's bottom padding.
static let reachBelow: CGFloat = 24
/// The sideways reach kept under half the distance between two lanes' scroll areas so only
/// one lane ever engages.
static let reachSide: CGFloat = 12
/// The region in the visible area's own coordinates, `(0, 0)` at its top-left a pointer
/// must be in to drive this scroller at all.
static func engagementRect(viewport: CGSize) -> CGRect {
CGRect(x: -reachSide,
y: -reachAbove,
width: viewport.width + reachSide * 2,
height: viewport.height + reachAbove + reachBelow)
}
/// Signed scroll velocity in points/second for a pointer at `position` along an axis whose
/// visible extent runs `0...length`: negative scrolls toward the start (content moves
/// down/right), positive toward the end.
///
/// `band` is clamped to half the extent, so the two bands of a short viewport meet rather than
/// overlap and its exact centre still resolves to "no scrolling".
static func velocity(position: CGFloat,
length: CGFloat,
band: CGFloat = band,
minSpeed: CGFloat = minSpeed,
maxSpeed: CGFloat = maxSpeed) -> CGFloat {
guard length > 0 else { return 0 }
let band = min(band, length / 2)
guard band > 0 else { return 0 }
let depth: CGFloat
let direction: CGFloat
if position < band {
depth = (band - position) / band
direction = -1
} else if position > length - band {
depth = (position - (length - band)) / band
direction = 1
} else {
return 0
}
return direction * (minSpeed + (maxSpeed - minSpeed) * min(max(depth, 0), 1))
}
/// Both axes at once for a pointer in the visible area's own coordinates.
static func velocity(pointer: CGPoint,
viewport: CGSize,
band: CGFloat = band,
minSpeed: CGFloat = minSpeed,
maxSpeed: CGFloat = maxSpeed) -> CGVector {
CGVector(
dx: velocity(position: pointer.x, length: viewport.width,
band: band, minSpeed: minSpeed, maxSpeed: maxSpeed),
dy: velocity(position: pointer.y, length: viewport.height,
band: band, minSpeed: minSpeed, maxSpeed: maxSpeed)
)
}
/// One tick's scroll offset: `current` advanced by `velocity` for `elapsed` seconds, clamped
/// into the scrollable range. An empty or inverted range (content shorter than the viewport)
/// pins to `minOffset`.
static func nextOffset(current: CGFloat,
velocity: CGFloat,
elapsed: CGFloat,
minOffset: CGFloat,
maxOffset: CGFloat) -> CGFloat {
let upper = max(minOffset, maxOffset)
return min(max(current + velocity * elapsed, minOffset), upper)
}
}
+287
View File
@@ -0,0 +1,287 @@
import CoreGraphics
/// Where a drag would land, as pure arithmetic no views, no session, no snapshot
/// (`DropSlotMathTests`). The full model this implements is **DRAG-REORDER.md** at the repository
/// root; the reasoning is reproduced here only where a signature would otherwise be a puzzle.
///
/// Three ideas run through everything below:
///
/// - **Resting-layout zones.** The proposal is an insertion index into the *resting* layout the
/// visible siblings laid out with the dragged run removed and no placeholder inserted. Slot `i`'s
/// zone is item `i`'s whole extent plus half the inter-item gap on each side; zones tile the
/// container, so a zone is entered exactly at its border and left only by entering another. The
/// zones are computed **analytically** from unit counts and frozen heights, never from measured
/// frames because measured frames are garbage precisely during the ~0.18s reflow a proposal
/// change triggers (03-board-ui.md § Motion, "motion never feeds back into logic").
/// - **Span-capped triggers.** Slot `i` triggers only while the cursor is over the span the dragged
/// run would *actually occupy* if dropped there the shadow run's future footprint. The far side
/// of a wider item's zone is a **dead region** (04-interactions.md Drag and drop: "no reflow
/// until the cursor reaches where the dragged lane would actually land").
/// - **Hysteresis, spelled `nil`.** A dead region returns `nil`, which means *hold the current
/// proposal* not "propose nothing" and not the caller's own value echoed back. The one
/// exception is a dead region hovered with no valid prior proposal (a fresh cross-board entry):
/// a drag in flight over a live target must always have *some* landing spot, so the containing
/// slot is proposed anyway.
///
/// Multi-drag is a single index for the whole run: the dragged items insert contiguously there, in
/// preserved flatten order (`SelectionGrammar.liveCards`). Nothing here knows how many shadows get
/// drawn only how wide the run is (`draggedSpan`), which is what the cap is measured in.
enum DropSlotMath {
// MARK: - Zones (one axis, shared by both layouts)
/// The boundaries separating consecutive slot zones, ascending, computed from the items'
/// extents in the resting layout.
///
/// `boundaries[i]` separates slot `i` from slot `i + 1`: for interior neighbours it is the
/// midpoint of the gap between item `i` and item `i + 1` ("half the gap on each side"); the
/// final boundary is the last item's trailing edge plus half a `gap`, beyond which lies the end
/// slot.
///
/// - Parameters:
/// - extents: each visible item's span along the layout axis, in resting positions with the
/// dragged run already removed, ascending.
/// - gap: the layout's inter-item spacing.
static func zoneBoundaries(extents: [ClosedRange<CGFloat>], gap: CGFloat) -> [CGFloat] {
guard !extents.isEmpty else { return [] }
var boundaries: [CGFloat] = []
for index in 0..<(extents.count - 1) {
boundaries.append((extents[index].upperBound + extents[index + 1].lowerBound) / 2)
}
boundaries.append(extents[extents.count - 1].upperBound + gap / 2)
return boundaries
}
/// The slot (`0...boundaries.count`) whose zone contains `cursor` the uncapped reading,
/// before any span cap applies.
///
/// - Parameters:
/// - cursor: pointer position along the layout axis, in `boundaries`' coordinate space.
/// - boundaries: `zoneBoundaries(extents:gap:)`, ascending.
/// - current: the currently proposed slot (`nil` when there is none). Consulted **only** to
/// break the tie when `cursor` sits on an exact boundary value: if `current` is one of the
/// two zones meeting there it is kept, so the shadow can never oscillate on a boundary
/// pixel.
static func containingSlot(cursor: CGFloat, boundaries: [CGFloat], current: Int?) -> Int {
let count = boundaries.count
guard count > 0 else { return 0 }
// Exact-boundary tie: the zones meeting at `cursor` are `b` and `b + 1`; keep the current
// proposal if it is one of them.
if let current,
let boundaryIndex = boundaries.firstIndex(of: cursor),
current == boundaryIndex || current == boundaryIndex + 1 {
return current
}
// Otherwise the containing zone: how many boundaries sit at or below the cursor (a zone is
// entered exactly at its border).
var index = 0
while index < count, cursor >= boundaries[index] { index += 1 }
return index
}
/// The span-capped slot for `cursor`, or `nil` to **hold** the current proposal.
///
/// Slot `i` triggers only while the cursor is over `[leading(i) gap/2, leading(i) +
/// draggedSpan + gap/2]` where the dragged run would sit after a drop there. Past that the
/// zone is dead and this answers `nil`, so dragging a 1× lane across a 3× lane does not reflow
/// while the cursor is over the 3× lane's far side; the shadow stays where it was until the
/// cursor reaches a spot the run could really land.
///
/// Two slots are never capped: the **end slot** (past the last item appending is the only
/// reading) and, by construction rather than by a special case, the region **before the first
/// item** (the cap only ever truncates a zone's far side, and slot 0's far side is inside the
/// container).
///
/// - Parameters:
/// - cursor: pointer position along the layout axis.
/// - extents: the visible items' resting spans with the dragged run removed, ascending.
/// - gap: the layout's inter-item spacing.
/// - draggedSpan: the dragged run's total extent when laid out the sum of its items' spans
/// plus the gaps between them.
/// - current: the currently proposed slot, or `nil`. A dead region with no valid `current`
/// proposes the containing slot (the fresh-entry rule); with one, it answers `nil`.
/// - Returns: a slot in `0...extents.count`, or `nil` meaning "no change".
static func slot(
cursor: CGFloat,
extents: [ClosedRange<CGFloat>],
gap: CGFloat,
draggedSpan: CGFloat,
current: Int?
) -> Int? {
guard !extents.isEmpty else { return 0 }
let boundaries = zoneBoundaries(extents: extents, gap: gap)
let index = containingSlot(cursor: cursor, boundaries: boundaries, current: current)
guard index < extents.count else { return index } // end slot: uncapped
let triggerStart = extents[index].lowerBound - gap / 2
if cursor <= triggerStart + draggedSpan + gap { return index }
// Dead region. Hold unless there is nothing to hold, in which case the containing zone
// is the answer: a drag in flight must always have some landing spot.
guard let current, (0...extents.count).contains(current) else { return index }
return nil
}
// MARK: - The lane strip
/// The lanes' resting extents along the strip, in strip coordinates (0 at the strip's leading
/// edge, the outer margin included) the layout `unitCounts` would have if it were the whole
/// strip.
///
/// The strip's outer margin is one `gap`, so the first slot starts at `gap`; each lane is
/// `LaneLayoutMath.slotWidth(units:standard:gap:)` wide and one `gap` follows it. Same
/// arithmetic `LaneReorderMath.centre` walks, in range form.
///
/// `unitCounts` is the **visible lanes minus the dragged run**. `standard` is *not* recomputed
/// for that shorter list: it is a function of the board's unit total, and a lane in flight is
/// still a lane on the board (DRAG-REORDER.md § The lane strip's resting layout is arithmetic).
static func laneExtents(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> [ClosedRange<CGFloat>] {
var extents: [ClosedRange<CGFloat>] = []
var left = gap
for units in unitCounts {
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
extents.append(left...(left + width))
left += width + gap
}
return extents
}
/// The total extent a run of dragged lanes occupies when laid out the sum of their slot
/// widths plus the `n 1` gaps between them. This is the span the trigger regions are capped
/// at, and it is exactly the shadow run's future footprint.
static func laneRunSpan(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat {
guard !unitCounts.isEmpty else { return 0 }
let widths = unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) }
return widths.reduce(0, +) + gap * CGFloat(unitCounts.count - 1)
}
/// Where a lane drag would land: an index into the ordered live lanes **with the dragged run
/// removed**, or `nil` to hold the current proposal.
///
/// - Parameters:
/// - cursorX: the pointer in strip coordinates. The *pointer*, not a measured replica frame
/// 03-board-ui.md § Motion.
/// - restingUnits: the remaining lanes' display units (`LaneLayoutMath.displayUnits`), in
/// board order, recomputed against each snapshot rather than frozen at drag start so a
/// foreign lane add mid-drag just moves the zones (04-interactions.md Drag and drop,
/// rule 1).
/// - draggedUnits: the dragged lanes' display units, in the order they will land.
/// - standard: the 1× lane width (`LaneLayoutMath.standardWidth`) of the board being dropped
/// **into** a cross-board arrival is measured in the destination's units.
/// - gap: the inter-lane gap, which is also the strip's outer margin.
/// - current: the currently proposed index, or `nil`.
static func laneSlot(
cursorX: CGFloat,
restingUnits: [Int],
draggedUnits: [Int],
standard: CGFloat,
gap: CGFloat,
current: Int?
) -> Int? {
slot(
cursor: cursorX,
extents: laneExtents(unitCounts: restingUnits, standard: standard, gap: gap),
gap: gap,
draggedSpan: laneRunSpan(unitCounts: draggedUnits, standard: standard, gap: gap),
current: current
)
}
// MARK: - The card masonry
/// Which interior column `x` falls in `0..<placement.columnCount`, clamped, so the lane's
/// padding and the region above its grid target the nearest column rather than nothing.
///
/// The bands tile: column `c` plus half a spacing on each side. `currentColumn` breaks an
/// exact-boundary tie exactly as `containingSlot` does in 1D.
static func columnIndex(atX x: CGFloat, placement: MasonryPlacement, currentColumn: Int?) -> Int {
let count = placement.columnCount
guard count > 1 else { return 0 }
let boundaries = (0..<(count - 1)).map {
placement.columnX($0) + placement.columnWidth + placement.spacing / 2
}
return containingSlot(cursor: x, boundaries: boundaries, current: currentColumn)
}
/// Where a card drag would land in a lane's masonry: a position in the lane's **logical** card
/// order (`0...heights.count`), or `nil` to hold the current proposal.
///
/// Cursor proposal in three steps (DRAG-REORDER.md § The card masonry):
///
/// 1. **Column** the cursor's x-band picks interior column `c`, clamped inward at the edges.
/// 2. **Row** column `c`'s cards are logical indices `c, c + C, c + 2C, `; their vertical
/// extents feed the *same* span-capped 1D machinery the strip uses, with `draggedSpan` the
/// first dragged card's frozen height. Dead regions hold; the tail slot below the column's
/// last card is uncapped.
/// 3. **Logical index** column `c`, row `r` is position `r * C + c`, clamped to
/// `heights.count`. Every column's tail slot maps at or past the end, so "below the last
/// card of any column" is the end slot: appending, which is the honest reading, since a
/// round-robin masonry has no landing spot below one column that is not simply the end.
///
/// - Parameters:
/// - cursor: the pointer in the same space as `placement.origin`.
/// - placement: the lane's resting grid geometry.
/// - heights: the lane's rendered cards' heights **minus the dragged ones**, in logical
/// order, **frozen at drag start** measured heights mid-flight are the animation-proof
/// rule's forbidden input.
/// - draggedHeight: the first dragged card's frozen height the run's footprint at the
/// landing spot, which is the trigger rect the cursor is over.
/// - current: the currently proposed logical index, or `nil`.
static func cardSlot(
cursor: CGPoint,
placement: MasonryPlacement,
heights: [CGFloat],
draggedHeight: CGFloat,
current: Int?
) -> Int? {
let count = heights.count
guard count > 0 else { return 0 }
let columns = placement.columnCount
// The proposal's own column, where it has one. The end slot belongs to every column's tail
// (each tail maps at or past the end), so it never rules a column out.
let currentColumn: Int? = {
guard let current, current >= 0, current < count else { return nil }
return placement.column(of: current)
}()
let column = columnIndex(atX: cursor.x, placement: placement, currentColumn: currentColumn)
let frames = placement.frames(heights: heights)
let positions = stride(from: column, to: count, by: columns).map { $0 }
let extents = positions.map { frames[$0].minY...frames[$0].maxY }
// The row this column would hold the current proposal at: its own row when the proposal
// lives in this column, this column's tail when the proposal is the end slot, and nothing
// when it belongs to another column where a hold would be meaningless.
let currentRow: Int? = {
guard let current, current >= 0 else { return nil }
if current >= count { return positions.count }
return placement.column(of: current) == column ? placement.row(of: current) : nil
}()
guard let row = slot(cursor: cursor.y, extents: extents, gap: placement.spacing,
draggedSpan: draggedHeight, current: currentRow)
else { return nil }
return min(placement.index(column: column, row: row), count)
}
// MARK: - Applying a proposal
/// `items` with the members at `moving` lifted out and re-inserted contiguously at `index`,
/// where `index` is counted **with them already removed** the convention every proposal and
/// every drop commit in this app shares.
///
/// The lifted members keep their given order (flatten order at the call sites), which is what
/// "drop inserts contiguously in preserved relative order" means (04-interactions.md Drag and
/// drop). Shared by the geometry's callers and by `BoardStore`'s no-op guard, so the shadow's
/// arrangement and the arrangement the store refuses to rewrite can never disagree.
static func applied<T: Equatable>(_ items: [T], moving: [T], to index: Int) -> [T] {
var remaining = items.filter { !moving.contains($0) }
let target = min(max(0, index), remaining.count)
remaining.insert(contentsOf: moving, at: target)
return remaining
}
}
+112 -18
View File
@@ -1,5 +1,88 @@
import CoreGraphics
import SwiftUI
/// Where a masonry puts its children, as pure arithmetic no views, no `Layout`, no measurement
/// (`MasonryPlacementTests`).
///
/// `MasonryLayout` below *is* this function plus SwiftUI's measurement cache, and the drag model
/// reconstructs a lane's resting card grid by replaying it over the frozen heights
/// (DRAG-REORDER.md § The card masonry). Extracting it is what makes those two the same
/// arithmetic rather than two implementations that agree until one of them is edited the
/// analytic-resting-layout rule (03-board-ui.md § Motion, "motion never feeds back into logic")
/// only pays off if what is computed analytically is what is actually drawn.
///
/// **The assignment is round-robin, and that is the whole model**: child `i` lands in column
/// `i % columnCount` at the bottom of that column's independent stack. Row `r` of column `c` is
/// therefore logical index `r * columnCount + c`, and the inverse is division which is how a
/// cursor position becomes an insertion index (`DropSlotMath.cardSlot`).
struct MasonryPlacement: Equatable, Sendable {
/// Number of interior columns (the lane's width units); clamped to 1 at every use.
let columnCount: Int
/// One column's width the standard card width, since every card is one column wide.
let columnWidth: CGFloat
/// Spacing between columns and between stacked cards within a column.
let spacing: CGFloat
/// The grid's top-leading corner, in whatever space the caller is working in.
let origin: CGPoint
init(columnCount: Int, columnWidth: CGFloat, spacing: CGFloat, origin: CGPoint = .zero) {
self.columnCount = max(1, columnCount)
self.columnWidth = columnWidth
self.spacing = spacing
self.origin = origin
}
/// The column width `columnCount` columns and their interior spacings divide `totalWidth`
/// into `MasonryLayout`'s own expression, floored at zero so a lane narrower than its
/// spacings never proposes a negative width.
static func columnWidth(totalWidth: CGFloat, columnCount: Int, spacing: CGFloat) -> CGFloat {
let count = CGFloat(max(1, columnCount))
return max(0, (totalWidth - spacing * (count - 1)) / count)
}
/// The interior column child `index` is assigned to.
func column(of index: Int) -> Int { index % columnCount }
/// The row within its column child `index` stacks at.
func row(of index: Int) -> Int { index / columnCount }
/// The logical position that row `row` of column `column` holds `column(of:)`/`row(of:)`
/// inverted. Unclamped: a caller asking for a column's tail row gets a position at or past
/// the end, which is exactly what the end slot means.
func index(column: Int, row: Int) -> Int { row * columnCount + column }
/// The leading x of interior column `column`.
func columnX(_ column: Int) -> CGFloat {
origin.x + CGFloat(column) * (columnWidth + spacing)
}
/// Every child's frame, in child order, for children of the given heights.
func frames(heights: [CGFloat]) -> [CGRect] {
var tops = [CGFloat](repeating: origin.y, count: columnCount)
return heights.enumerated().map { index, height in
let target = column(of: index)
let frame = CGRect(x: columnX(target), y: tops[target], width: columnWidth, height: height)
tops[target] += height + spacing
return frame
}
}
/// The grid's total height the tallest column's stack, which is what `sizeThatFits`
/// reports.
func height(heights: [CGFloat]) -> CGFloat {
var totals = [CGFloat](repeating: 0, count: columnCount)
for (index, height) in heights.enumerated() {
let target = column(of: index)
totals[target] += height + (totals[target] > 0 ? spacing : 0)
}
return totals.max() ?? 0
}
}
/// Masonry layout for a lane's interior card columns (03-board-ui.md § Layout full visibility:
/// "a wide lane flows them into as many interior masonry columns as it has units"; § Lane: "masonry
/// grid when wide settled, the pathfinder's masonry works").
@@ -30,7 +113,16 @@ struct MasonryLayout: Layout {
private var columnCount: Int { max(1, columns) }
private func columnWidth(for totalWidth: CGFloat) -> CGFloat {
max(0, (totalWidth - spacing * CGFloat(columnCount - 1)) / CGFloat(columnCount))
MasonryPlacement.columnWidth(totalWidth: totalWidth, columnCount: columnCount, spacing: spacing)
}
/// The placement arithmetic for a grid of `width` points at `origin` the one expression both
/// this layout and the drag model's resting grid go through (`MasonryPlacement`).
private func placement(width: CGFloat, origin: CGPoint) -> MasonryPlacement {
MasonryPlacement(columnCount: columnCount,
columnWidth: columnWidth(for: width),
spacing: spacing,
origin: origin)
}
// MARK: - Measurement cache
@@ -75,28 +167,30 @@ struct MasonryLayout: Layout {
return height
}
/// Every subview's height at `column` width, in subview order the input `MasonryPlacement`
/// takes, gathered through the cache above so both passes measure once between them.
private func measuredHeights(of subviews: Subviews, at column: CGFloat, cache: inout Cache) -> [CGFloat] {
var heights: [CGFloat] = []
heights.reserveCapacity(subviews.count)
for index in subviews.indices {
heights.append(height(of: subviews, at: index, column: column, cache: &cache))
}
return heights
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
let width = proposal.width ?? 0
let column = columnWidth(for: width)
var heights = [CGFloat](repeating: 0, count: columnCount)
for index in subviews.indices {
let height = height(of: subviews, at: index, column: column, cache: &cache)
let target = index % columnCount
heights[target] += height + (heights[target] > 0 ? spacing : 0)
}
return CGSize(width: width, height: heights.max() ?? 0)
let placement = placement(width: width, origin: .zero)
let heights = measuredHeights(of: subviews, at: placement.columnWidth, cache: &cache)
return CGSize(width: width, height: placement.height(heights: heights))
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
let column = columnWidth(for: bounds.width)
var y = [CGFloat](repeating: bounds.minY, count: columnCount)
for index in subviews.indices {
let target = index % columnCount
let x = bounds.minX + CGFloat(target) * (column + spacing)
let height = height(of: subviews, at: index, column: column, cache: &cache)
subviews[index].place(at: CGPoint(x: x, y: y[target]),
proposal: ProposedViewSize(width: column, height: height))
y[target] += height + spacing
let placement = placement(width: bounds.width, origin: bounds.origin)
let heights = measuredHeights(of: subviews, at: placement.columnWidth, cache: &cache)
for (index, frame) in placement.frames(heights: heights).enumerated() {
subviews[index].place(at: frame.origin,
proposal: ProposedViewSize(width: frame.width, height: frame.height))
}
}
}
+7 -1
View File
@@ -396,7 +396,13 @@ private struct TrashEntryRow: View {
// A drop over anything but a live lane the trash itself, a gap, the outer margin
// writes nothing. There is no replica to snap back; the row never left.
guard let lane = drag.laneUnder(value.location.x) else { return }
store.restoreByDrag(cardID: entry.id, intoLane: lane)
// m5-drag phase 2: the drop position comes from `DropSlotMath.cardSlot` once this
// gesture is replaced by the real drag session. Until then the interim is the
// destination lane's bottom, which is the index past its last rendered card.
let bottom = store.snapshot.lanes
.first { $0.id == lane }?
.cards.filter { !$0.isDeleted }.count ?? 0
store.restoreByDrag(cardID: entry.id, intoLane: lane, at: bottom)
}
}