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
This commit is contained in:
@@ -100,6 +100,11 @@ struct LaneView: View {
|
||||
/// tall as the strip gives it.
|
||||
@State private var measuredHeight: CGFloat = 0
|
||||
|
||||
/// The title bar's drawn height — **the replica's anchor** (`dragReplica`), measured for the same
|
||||
/// reason the lane's height is: the bar is as tall as the text in it, at whatever size the system
|
||||
/// is set to.
|
||||
@State private var measuredHeaderHeight: CGFloat = 0
|
||||
|
||||
/// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
|
||||
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
||||
@State private var autoScroller = DragAutoScroller()
|
||||
@@ -190,6 +195,10 @@ struct LaneView: View {
|
||||
// past the header and would stop being a boundary at all. The bar itself never moves.
|
||||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
||||
drops.registry.update(header: frame, for: lane.id)
|
||||
// The same measurement answers a second question, so it is read once: the bar is
|
||||
// what a lane drag is grabbed by, and its height is where the replica has to hang
|
||||
// from the pointer (`replicaHeaderCenterY`).
|
||||
measuredHeaderHeight = frame.height
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeader(lane.id) }
|
||||
.overlay(alignment: .trailing) { newCardButton }
|
||||
@@ -530,8 +539,17 @@ struct LaneView: View {
|
||||
/// A static rendition rather than a live `LaneView`: a drag image is a snapshot, so it carries no
|
||||
/// scrolling, no gestures and no geometry observers, and the card list is capped because anything
|
||||
/// past the lane's height is clipped anyway.
|
||||
///
|
||||
/// **The pointer keeps the point it grabbed** — the Finder-icon promise, and the whole of what
|
||||
/// the anchoring padding below buys. SwiftUI centres a preview on the view the drag started
|
||||
/// from, which here is the *title bar*: uncompensated, a full-height replica centred on a bar a
|
||||
/// line and a half tall hangs half a lane above the cursor, and the cursor lands in the middle of
|
||||
/// the image rather than on the bar it grabbed. Padding the replica so its own title bar is the
|
||||
/// image's centre undoes exactly that, and lands every other pixel of the replica over the lane
|
||||
/// it was lifted from (`DragPreviewAnchor`, which is where the arithmetic and its reasoning live).
|
||||
private var dragReplica: some View {
|
||||
let count = max(1, draggedLaneCount)
|
||||
let anchor = DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
|
||||
@@ -539,6 +557,11 @@ struct LaneView: View {
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
||||
// Transparent, and only ever on one side — see `DragPreviewAnchor`, whose figure is
|
||||
// deliberately invariant under the symmetric margin above, so the two paddings compose in
|
||||
// any order.
|
||||
.padding(.top, anchor.before)
|
||||
.padding(.bottom, anchor.after)
|
||||
// **Back to the window's own appearance**, undoing `header`'s runtime-contrast override for
|
||||
// this one subtree (`boardTextInk`). The preview is attached inside that modifier and would
|
||||
// otherwise inherit it — but the replica is not text on the board background: it draws its
|
||||
@@ -583,15 +606,35 @@ struct LaneView: View {
|
||||
}
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
.frame(
|
||||
width: max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize)),
|
||||
height: max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize)),
|
||||
alignment: .topLeading
|
||||
)
|
||||
.frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
/// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
|
||||
/// (`BoardMetrics`). Named rather than inlined in the frame because the anchoring below has to
|
||||
/// ask the same question the drawing does, and two derivations of one figure would be two
|
||||
/// answers.
|
||||
private var replicaWidth: CGFloat {
|
||||
max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
private var replicaHeight: CGFloat {
|
||||
max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
/// Where the title bar sits inside the replica — its **centre**, measured down from the replica's
|
||||
/// top edge, which is the point the pointer must hold (`dragReplica`).
|
||||
///
|
||||
/// The replica stacks exactly what the lane stacks, in the same order and off the same figures,
|
||||
/// so this is the lane's own layout arithmetic rather than a second description of it: the accent
|
||||
/// band when the lane's colour resolves to one (`accentBand` draws nothing when it does not, and
|
||||
/// contributes no height either), the plate's inset, and half the measured bar.
|
||||
private var replicaHeaderCenterY: CGFloat {
|
||||
let band = Palette.color(for: lane.background) == nil ? 0 : bandHeight
|
||||
return band + BoardMetrics.lanePlatePadding(bodyPointSize: pointSize) + measuredHeaderHeight / 2
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
/// The card stack. Its empty space is a click target in its own right (04 ▸ Selection): one
|
||||
|
||||
Reference in New Issue
Block a user