import SwiftUI /// The **shadow placeholder** — the outline occupying an item's proposed landing spot /// (DRAG-REORDER.md § The pieces). /// /// One shape for both the drag's shadows and the lane resize's resting footprint, because they are /// the same thing said twice: "here is the space this item occupies in the layout". They differ only /// in whether the space is a *proposal* — the drag's is dashed, because a dashed outline is what /// 03-board-ui.md's placeholder vocabulary asks for and what makes "the drop lands exactly here" /// legible; the resize's is a plain fill, because the lane is already there. /// /// **Hit-transparent, always.** The strip's own drop target has to stay live beneath the shadows /// (DRAG-REORDER.md § Single-target dispatch: "shadow placeholders are hit-transparent, so the strip /// target stays live beneath them"), and a shadow that swallowed the release would strand the drop. struct DragShadow: View { /// Matched to the surface it stands in for — the lane's plate radius or the card's, both /// font-derived (`BoardMetrics`) and both supplied by the caller, which is the one that knows /// which surface this shadow is standing in for. let cornerRadius: CGFloat /// A drop proposal (dashed) or a resting footprint (plain). var dashed: Bool = true /// Increase Contrast and Reduce Transparency, for the outline and the fill below /// (10-accessibility.md; `Accommodations`). A shadow's whole job is to say "here", and both /// halves of how it says so are alpha-based by default. @Environment(\.colorSchemeContrast) private var contrast @Environment(\.accessibilityReduceTransparency) private var reduceTransparency var body: some View { RoundedRectangle(cornerRadius: cornerRadius) .fill(Accommodations.dragShadowWash(reduceTransparency: reduceTransparency).style) .overlay { if dashed { RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder( Color.accentColor.opacity(Accommodations.accentOpacity(0.55, contrast: contrast)), style: StrokeStyle( lineWidth: Accommodations.borderWidth(1.5, contrast: contrast), dash: [6] ) ) } } .allowsHitTesting(false) .accessibilityHidden(true) } } /// The count badge a multi-drag's replica wears — Finder's own affordance for "this many are coming /// with me" (04-interactions.md ▸ Drag and drop: "dragging any member of a multi-selection drags the /// whole selection"). /// /// Draws nothing for a single item, so every replica can carry it unconditionally. struct DragCountBadge: View { let count: Int /// The board's ruler (`BoardZoom`) — the badge sits over the shadow of a card drawn at this zoom, /// so it has to be sized on the same one. @Environment(\.boardZoom) private var zoom /// The badge is a disc around a numeral, so every figure in it follows the numeral's font /// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule) — at the standard body size /// they are the 6, 3 and 10 points it has always drawn. private var pointSize: CGFloat { zoom.bodyPointSize } var body: some View { if count > 1 { Text("\(count)") .boardFont(.caption2, weight: .bold) .monospacedDigit() .foregroundStyle(.white) .padding(.horizontal, BoardMetrics.em(0.45, bodyPointSize: pointSize)) .padding(.vertical, BoardMetrics.em(0.25, bodyPointSize: pointSize)) .background(Circle().fill(Color.accentColor)) .offset( x: BoardMetrics.em(0.75, bodyPointSize: pointSize), y: -BoardMetrics.em(0.75, bodyPointSize: pointSize) ) } } } /// The pickup lift's shadow figures — 03-board-ui.md § Motion's "the pickup lift (scale + shadow as /// it detaches from the card)": the system drag session supplies the scale and the tracking, but /// paints no shadow onto its preview image, so the shadow can only come from the replica view /// itself. Values are the pathfinder's own constant ambient shadow, reused verbatim rather than /// re-derived (`~/Documents/indie/Kanban` CardView.swift:623) — its drags read subtly raised, and /// that read is the point. enum DragReplicaStyle { static let shadowColor = Color.black.opacity(0.16) static let shadowRadius: CGFloat = 2.5 static let shadowY: CGFloat = 1.5 } extension View { /// The pickup lift's shadow (`DragReplicaStyle`), for a drag replica's face — shared by the /// card, lane, and trashed-lane replicas so the figures are spelled once. /// /// **It re-asserts the board's zoom, and that is not decoration.** A `.onDrag(_:preview:)` /// preview is hosted for the drag *image*, outside the strip's view hierarchy, so the environment /// the replica's own `.boardFont(_:)` modifiers resolve against is not reliably the board's. The /// replica must be drawn on the same ruler as the face it was lifted from — otherwise the image /// under the cursor is a card the board does not contain, which is the exact failure /// `BoardMetrics.cardReplicaWidth(measured:)` exists to prevent on the width axis. Passing the /// level in explicitly closes the type axis the same way. /// /// It rides on this modifier rather than being a fourth line in each replica for the reason the /// shadow does: three replicas, one rule, spelled once. func dragReplicaShadow(zoom: BoardZoomContext) -> some View { environment(\.boardZoom, zoom) .shadow(color: DragReplicaStyle.shadowColor, radius: DragReplicaStyle.shadowRadius, y: DragReplicaStyle.shadowY) } }