Files
lanework/Kanban/UI/Board/DragPreviewAnchor.swift
rzen f83385e79f Lane drag preview attaches at the grabbed title bar, not its middle
SwiftUI's .onDrag(_:preview:) exposes no anchor or grab-point API and
lays the preview centered over the grabbed view, so a full-height lane
replica grabbed by its ~1.5-line title bar hung half a lane above the
pointer. DragPreviewAnchor is the pure fix: transparent one-sided
padding computed so the padded image's center IS the replica's title
bar - the system's centering then lands the bar under the cursor and
the body over the lane it was lifted from. LaneView measures the real
bar height in the geometry observer it already runs, accounting for the
accent band when the lane's color resolves.

Pixel-exact grab preservation would need re-homing the whole gesture
stack onto an AppKit beginDraggingSession path; declined for a polish
card - this gets the cursor onto the grabbed bar under either of
SwiftUI's possible placement rules. Manual verification pending (no
display here): grab specific bar pixels, colored + uncolored lanes,
large text sizes, multi-lane fan, cross-board drop geometry.

9 tests. 1661 green on both schemes with the paired card-width fix.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 13:02:57 -04:00

55 lines
3.2 KiB
Swift

import CoreGraphics
/// Where a drag preview attaches to the pointer, as pure arithmetic (`DragPreviewAnchorTests`).
///
/// **SwiftUI gives no anchor control.** `.onDrag(_:preview:)` lays the preview over the *grabbed
/// view's* frame, centred on it — which is exactly right while the preview is the size of what was
/// grabbed (a card face lifting off its own plate), and wrong the moment it is not. A lane is
/// grabbed by its title bar and its replica is the whole lane (03-board-ui.md § Motion: "a faithful,
/// full-size replica of the dragged item — the whole lane, not the strip of title bar that was
/// grabbed"), so centring that replica on the bar hangs half a lane above the pointer: the cursor
/// ends up in the middle of the image instead of on the pixel it grabbed.
///
/// The one lever the API does leave is the preview's own geometry, and **transparent padding moves a
/// view's centre without moving the view**: pad the side opposite the anchor by however much the
/// anchor is off-centre and the padded box's centre lands exactly on it. The system then centres
/// *that* box on the grabbed view, which puts the replica's title bar over the real one — and, since
/// the replica is drawn at the lane's own size, every other pixel of it over the lane it was lifted
/// from. That is the Finder-icon promise stated as geometry: the cursor stays where it grabbed.
///
/// Axis-agnostic, and said once because both axes ask the same question. The horizontal one answers
/// zero for a lane — a lane replica is as wide as its lane, so its header's centre is already the
/// box's — and the arithmetic is written for both anyway rather than assuming that stays true.
///
/// The compensation is **invariant under symmetric padding**: the drag replicas wear a transparent
/// margin so their shadows are not clipped (`BoardMetrics.replicaPadding`), and adding `p` to both
/// ends adds `p` to the length and `p` to the anchor, which cancels. So a caller may compute this
/// from the replica's own size and apply it outside that margin, in either order.
enum DragPreviewAnchor {
/// Extra transparent length to add before and after a preview along one axis.
struct Padding: Equatable, Sendable {
var before: CGFloat = 0
var after: CGFloat = 0
static let none = Padding()
}
/// The padding that makes `anchor` — a distance from the preview's leading edge — the centre of
/// a preview `length` long.
///
/// Only ever one side of it: an anchor above the middle needs room before it, one below needs
/// room after it, and the exact middle needs none. The anchor is clamped into the preview, so a
/// stale measurement can shift the image by at most its own length.
///
/// Degenerate input — an unmeasured, zero or non-finite length — pads nothing, which draws the
/// preview exactly as it was drawn before this existed rather than flinging it off the pointer.
static func padding(length: CGFloat, anchor: CGFloat) -> Padding {
guard length.isFinite, anchor.isFinite, length > 0 else { return .none }
let clamped = min(max(anchor, 0), length)
let delta = length - 2 * clamped
return delta > 0 ? Padding(before: delta) : Padding(after: -delta)
}
}