Phase 04: Drag Interaction - 2 plans in 2 waves - Plan 01: Migrate to modern drag-drop delegates, lift animation, haptics - Plan 02: Themed insertion line, invalid zone feedback, snap-back animation Ready for execution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
12 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-drag-interaction | 02 | execute | 2 |
|
|
false |
|
Purpose: Complete the drag-drop UX with clear visual feedback per CONTEXT.md decisions. Users know exactly where items will land and get clear rejection feedback for invalid positions.
Output: Polished drag-drop with insertion line, invalid zone visualization, and smooth rejection animation.
<execution_context>
@/.claude/get-shit-done/workflows/execute-plan.md
@/.claude/get-shit-done/templates/summary.md
</execution_context>
-
Add private class
InsertionLineView: UIViewinside the file:private class InsertionLineView: UIView { private let lineLayer = CAShapeLayer() override init(frame: CGRect) { super.init(frame: frame) setup() } required init?(coder: NSCoder) { super.init(coder: coder) setup() } private func setup() { // Plain line, 3pt thickness (middle of 2-4pt range per CONTEXT.md) lineLayer.lineWidth = 3.0 lineLayer.lineCap = .round layer.addSublayer(lineLayer) alpha = 0 // Start hidden updateThemeColor() } func updateThemeColor() { // Get theme color from Theme.warmOrange (which adapts to current AppTheme) lineLayer.strokeColor = UIColor(Theme.warmOrange).cgColor } override func layoutSubviews() { super.layoutSubviews() let path = UIBezierPath() let margin: CGFloat = 16 path.move(to: CGPoint(x: margin, y: bounds.midY)) path.addLine(to: CGPoint(x: bounds.width - margin, y: bounds.midY)) lineLayer.path = path.cgPath } func fadeIn() { UIView.animate(withDuration: 0.15) { self.alpha = 1.0 } } func fadeOut() { UIView.animate(withDuration: 0.15) { self.alpha = 0 } } } -
Add property to controller:
private var insertionLine: InsertionLineView? -
Add helper methods to show/hide insertion line:
private func showInsertionLine(at indexPath: IndexPath) { if insertionLine == nil { insertionLine = InsertionLineView() tableView.addSubview(insertionLine!) } insertionLine?.updateThemeColor() // Refresh in case theme changed let rect = tableView.rectForRow(at: indexPath) insertionLine?.frame = CGRect( x: 0, y: rect.minY - 2, // Position above target row width: tableView.bounds.width, height: 6 // Slightly larger than line for touch tolerance ) insertionLine?.fadeIn() } private func hideInsertionLine() { insertionLine?.fadeOut() } -
Wire into
dropSessionDidUpdate:- When position is valid: call
showInsertionLine(at: destinationIndexPath) - When position is invalid or no destination: call
hideInsertionLine()
- When position is valid: call
-
In
dropSessionDidEnd: callhideInsertionLine()and setinsertionLine = nilto clean up Build succeeds and run on simulator: -
Drag an item
-
Insertion line appears between rows in theme color
-
Line fades in/out smoothly as drag moves
-
Line disappears on drop Themed insertion line appears at drop target position, fades in/out with 150ms animation, uses Theme.warmOrange color.
-
Add property to track the current drag snapshot:
private var currentDragSnapshot: UIView? -
Store snapshot reference in
dragSessionWillBegin:if let context = session.localContext as? DragContext { currentDragSnapshot = context.snapshot } -
Add helper methods for invalid tint:
private func applyInvalidTint() { guard let snapshot = currentDragSnapshot else { return } UIView.animate(withDuration: 0.1) { // Create red overlay on the snapshot let overlay = snapshot.viewWithTag(999) ?? { let v = UIView(frame: snapshot.bounds) v.tag = 999 v.backgroundColor = UIColor.systemRed.withAlphaComponent(0.15) v.layer.cornerRadius = 12 v.alpha = 0 snapshot.addSubview(v) return v }() overlay.alpha = 1.0 } } private func removeInvalidTint() { guard let snapshot = currentDragSnapshot, let overlay = snapshot.viewWithTag(999) else { return } UIView.animate(withDuration: 0.1) { overlay.alpha = 0 } } -
In
dropSessionDidUpdate:- When returning
.forbidden: callapplyInvalidTint() - When returning
.move: callremoveInvalidTint()
- When returning
-
In
dropSessionDidEnd: callremoveInvalidTint()and clearcurrentDragSnapshotRun on simulator: -
Drag a travel segment to an invalid day (outside its valid range)
-
Dragged item shows red tint overlay
-
Move back to valid zone - red tint disappears Dragged items show red tint (systemRed at 15% opacity) when hovering over invalid drop zones, tint fades with 100ms animation.
-
Add error haptic generator:
private let errorHaptic = UINotificationFeedbackGenerator() -
Add triple-tap error method:
private func playErrorHaptic() { // First tap - error notification errorHaptic.notificationOccurred(.error) // Two additional quick taps for "nope" feeling DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in self?.lightHaptic.impactOccurred() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in self?.lightHaptic.impactOccurred() } } -
Add snap-back animation method:
private func performSnapBack(snapshot: UIView, to originalFrame: CGRect, completion: @escaping () -> Void) { // Per CONTEXT.md: ~200ms with slight overshoot UIView.animate( withDuration: 0.2, delay: 0, usingSpringWithDamping: 0.7, // Creates overshoot initialSpringVelocity: 0.3, options: [] ) { snapshot.layer.transform = CATransform3DIdentity snapshot.frame = originalFrame snapshot.layer.shadowOpacity = 0 // Remove red tint snapshot.viewWithTag(999)?.alpha = 0 } completion: { _ in snapshot.removeFromSuperview() completion() } } -
Modify
performDropWith coordinator:to handle invalid drops:- If destinationIndexPath is nil or validation fails:
- Get DragContext from session
- Call
playErrorHaptic() - Call
performSnapBack(snapshot:to:completion:)with original frame - In completion: restore source cell alpha to 1, clean up state
- Return early (don't perform the move)
- If destinationIndexPath is nil or validation fails:
-
Ensure successful drops still use the existing animation path with medium haptic.
-
In
dropSessionDidEnd: Handle case where drop was cancelled (not just invalid):- If currentDragSnapshot still exists, perform snap-back Run on simulator:
-
Drag travel to invalid day
-
Lift finger to drop
-
Item snaps back to original position with spring overshoot
-
On device: triple-tap haptic is felt Invalid drops trigger snap-back animation (200ms spring with overshoot) and triple-tap error haptic pattern.
On physical device only: 7. Verify haptics:
- Light tap on grab
- Medium tap on successful drop
- Triple-tap "nope" on invalid drop Type "approved" if all behaviors work correctly, or describe any issues
- Build succeeds without warnings
- All 8 DRAG requirements verified:
- DRAG-01: Lift animation (scale + shadow)
- DRAG-02: Insertion line between items
- DRAG-03: Items shuffle (automatic with drop delegate)
- DRAG-04: Magnetic snap on drop (coordinator.drop)
- DRAG-05: Invalid drops rejected with snap-back
- DRAG-06: Haptic on grab (light) and drop (medium)
- DRAG-07: Auto-scroll at viewport edge (built-in)
- DRAG-08: Tilt during drag (2 degrees)
- Theme-aware insertion line
- Error handling with red tint + snap-back + triple-tap haptic
<success_criteria> All ROADMAP Phase 4 Success Criteria verified:
- User sees clear insertion line indicating where item will land during drag
- Dropping on invalid target snaps item back to original position with haptic feedback
- Dragging to bottom of visible area auto-scrolls to reveal more content
- Complete drag-drop cycle feels responsive with visible lift, shuffle, and settle animations
- Haptic pulses on both grab and drop (verifiable on physical device) </success_criteria>