docs(04): research drag interaction phase
Phase 4: Drag Interaction - UITableViewDragDelegate/DropDelegate migration patterns - Custom lift animation (scale, shadow, tilt) - Themed insertion line implementation - Haptic feedback integration - Invalid drop rejection with snap-back - Auto-scroll behavior documentation - Constraint integration with existing ItineraryConstraints Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
904
.planning/phases/04-drag-interaction/04-RESEARCH.md
Normal file
904
.planning/phases/04-drag-interaction/04-RESEARCH.md
Normal file
@@ -0,0 +1,904 @@
|
||||
# Phase 4: Drag Interaction - Research
|
||||
|
||||
**Researched:** 2026-01-18
|
||||
**Domain:** UITableView drag-drop interaction, haptic feedback, custom animations
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 4 enhances the existing `ItineraryTableViewController` with rich drag-and-drop visual feedback. The codebase already implements reordering using the legacy UITableView methods (`canMoveRowAt`, `moveRowAt`, `targetIndexPathForMoveFromRowAt`). This research focuses on adding the Phase 4 requirements: lift animation, custom insertion line, item shuffle, tilt transform, magnetic snap, and haptic feedback.
|
||||
|
||||
The current implementation uses UITableView in "edit mode" (`isEditing = true`) which shows native drag handles. Per CONTEXT.md decisions, the lift feel should match iOS Reminders (quick, light, responsive), with 1:1 finger tracking, immediate shuffle, and subtle scale (1.02-1.03x). The insertion line should follow the theme color, fade in/out, and be plain without embellishments.
|
||||
|
||||
**Primary recommendation:** Migrate from legacy `canMoveRowAt`/`moveRowAt` to the modern `UITableViewDragDelegate`/`UITableViewDropDelegate` protocols. This unlocks custom drag previews, precise insertion feedback, and full control over drop validation animations.
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| UIKit (UITableViewDragDelegate) | iOS 11+ | Drag initiation and preview | Apple's official API for table drag |
|
||||
| UIKit (UITableViewDropDelegate) | iOS 11+ | Drop handling and insertion | Apple's official API for table drop |
|
||||
| UIImpactFeedbackGenerator | iOS 10+ | Haptic feedback on grab/drop | Standard haptic API |
|
||||
| UINotificationFeedbackGenerator | iOS 10+ | Error haptic for invalid drops | Standard notification haptic |
|
||||
| CATransform3D | Core Animation | 3D tilt during drag | Standard layer transform |
|
||||
| UIBezierPath | UIKit | Custom insertion line shape | Standard path drawing |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| UISelectionFeedbackGenerator | iOS 10+ | Subtle selection haptic | When crossing valid drop zones |
|
||||
| CABasicAnimation | Core Animation | Fade/scale animations | Insertion line appearance |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Custom insertion view | Default UITableView separator | Default lacks theme color and fade animation |
|
||||
| UIViewPropertyAnimator | CABasicAnimation | Either works; PropertyAnimator more flexible for interruptible animations |
|
||||
| UIDragPreview transform | CALayer transform on cell | UIDragPreview is system-managed; layer transform gives full control |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Current Architecture (Legacy Methods)
|
||||
|
||||
The existing `ItineraryTableViewController` uses legacy reordering:
|
||||
|
||||
```swift
|
||||
// Source: ItineraryTableViewController.swift (current)
|
||||
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
|
||||
return flatItems[indexPath.row].isReorderable
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath,
|
||||
to destinationIndexPath: IndexPath) {
|
||||
// Called AFTER drop - updates data model
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath,
|
||||
toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
|
||||
// Called DURING drag - validates/clamps drop position
|
||||
}
|
||||
```
|
||||
|
||||
**Limitation:** Legacy methods provide no control over:
|
||||
- Drag preview appearance (scale, shadow, tilt)
|
||||
- Insertion line customization
|
||||
- Lift/drop animation timing
|
||||
|
||||
### Pattern 1: Modern Drag-Drop Delegate Migration
|
||||
|
||||
**What:** Adopt `UITableViewDragDelegate` and `UITableViewDropDelegate` protocols
|
||||
**When to use:** All Phase 4 requirements
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
final class ItineraryTableViewController: UITableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Modern drag-drop setup (replaces isEditing = true)
|
||||
tableView.dragDelegate = self
|
||||
tableView.dropDelegate = self
|
||||
tableView.dragInteractionEnabled = true
|
||||
|
||||
// Keep isEditing for visual consistency if needed
|
||||
// but drag is now handled by delegates
|
||||
}
|
||||
}
|
||||
|
||||
extension ItineraryTableViewController: UITableViewDragDelegate {
|
||||
|
||||
// REQUIRED: Initiate drag
|
||||
func tableView(_ tableView: UITableView,
|
||||
itemsForBeginning session: UIDragSession,
|
||||
at indexPath: IndexPath) -> [UIDragItem] {
|
||||
|
||||
let item = flatItems[indexPath.row]
|
||||
guard item.isReorderable else { return [] } // Empty = no drag
|
||||
|
||||
// Store context for constraint validation
|
||||
session.localContext = DragContext(item: item, sourceIndex: indexPath)
|
||||
|
||||
// Create drag item (required for system)
|
||||
let provider = NSItemProvider(object: item.id as NSString)
|
||||
let dragItem = UIDragItem(itemProvider: provider)
|
||||
dragItem.localObject = item
|
||||
|
||||
return [dragItem]
|
||||
}
|
||||
|
||||
// OPTIONAL: Customize drag preview (LIFT ANIMATION)
|
||||
func tableView(_ tableView: UITableView,
|
||||
dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
|
||||
|
||||
guard let cell = tableView.cellForRow(at: indexPath) else { return nil }
|
||||
|
||||
let params = UIDragPreviewParameters()
|
||||
|
||||
// Rounded corners for lift preview
|
||||
let rect = cell.contentView.bounds.insetBy(dx: 4, dy: 2)
|
||||
params.visiblePath = UIBezierPath(roundedRect: rect, cornerRadius: 12)
|
||||
|
||||
// Background matches card
|
||||
params.backgroundColor = .clear
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// OPTIONAL: Called when drag begins (HAPTIC GRAB)
|
||||
func tableView(_ tableView: UITableView,
|
||||
dragSessionWillBegin session: UIDragSession) {
|
||||
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
|
||||
// Apply lift transform to source cell
|
||||
// (handled separately via custom snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
extension ItineraryTableViewController: UITableViewDropDelegate {
|
||||
|
||||
// REQUIRED: Handle drop
|
||||
func tableView(_ tableView: UITableView,
|
||||
performDropWith coordinator: UITableViewDropCoordinator) {
|
||||
|
||||
guard let item = coordinator.items.first,
|
||||
let sourceIndexPath = item.sourceIndexPath,
|
||||
let destinationIndexPath = coordinator.destinationIndexPath else { return }
|
||||
|
||||
// Use existing moveRowAt logic
|
||||
let rowItem = flatItems[sourceIndexPath.row]
|
||||
flatItems.remove(at: sourceIndexPath.row)
|
||||
flatItems.insert(rowItem, at: destinationIndexPath.row)
|
||||
|
||||
// Notify callbacks (existing logic)
|
||||
// ...
|
||||
|
||||
// Animate into place
|
||||
coordinator.drop(item.dragItem, toRowAt: destinationIndexPath)
|
||||
|
||||
// Drop haptic
|
||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
// OPTIONAL: Return drop proposal (INSERTION LINE)
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidUpdate session: UIDropSession,
|
||||
withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
|
||||
|
||||
guard let context = session.localDragSession?.localContext as? DragContext else {
|
||||
return UITableViewDropProposal(operation: .forbidden)
|
||||
}
|
||||
|
||||
// Validate using existing constraint logic
|
||||
let isValid = validateDropPosition(context: context, at: destinationIndexPath)
|
||||
|
||||
if isValid {
|
||||
return UITableViewDropProposal(
|
||||
operation: .move,
|
||||
intent: .insertAtDestinationIndexPath
|
||||
)
|
||||
} else {
|
||||
return UITableViewDropProposal(operation: .forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONAL: Called when drag ends
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidEnd session: UIDropSession) {
|
||||
|
||||
removeCustomInsertionLine()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Custom Lift Animation (Scale + Shadow + Tilt)
|
||||
|
||||
**What:** Apply transform to create "lifted" appearance when drag begins
|
||||
**When to use:** DRAG-01 (lift animation), DRAG-08 (tilt)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
|
||||
/// Creates a custom snapshot view with lift styling
|
||||
private func createLiftedSnapshot(for cell: UITableViewCell) -> UIView {
|
||||
// Create snapshot
|
||||
let snapshot = cell.snapshotView(afterScreenUpdates: false) ?? UIView()
|
||||
snapshot.frame = cell.frame
|
||||
|
||||
// Apply subtle scale (1.02-1.03x per CONTEXT.md)
|
||||
let scale: CGFloat = 1.025
|
||||
|
||||
// Apply tilt (2-3 degrees per DRAG-08)
|
||||
let tiltAngle: CGFloat = 2.0 * .pi / 180.0 // 2 degrees in radians
|
||||
|
||||
// Combine transforms
|
||||
var transform = CATransform3DIdentity
|
||||
|
||||
// Add perspective for 3D effect
|
||||
transform.m34 = -1.0 / 500.0
|
||||
|
||||
// Scale
|
||||
transform = CATransform3DScale(transform, scale, scale, 1.0)
|
||||
|
||||
// Rotate around Y axis for tilt
|
||||
transform = CATransform3DRotate(transform, tiltAngle, 0, 1, 0)
|
||||
|
||||
snapshot.layer.transform = transform
|
||||
|
||||
// Add shadow
|
||||
snapshot.layer.shadowColor = UIColor.black.cgColor
|
||||
snapshot.layer.shadowOffset = CGSize(width: 0, height: 8)
|
||||
snapshot.layer.shadowRadius = 16
|
||||
snapshot.layer.shadowOpacity = 0.25
|
||||
snapshot.layer.masksToBounds = false
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// Animates cell "lifting" on grab
|
||||
private func animateLift(for cell: UITableViewCell, snapshot: UIView) {
|
||||
// Initial state (no transform)
|
||||
snapshot.layer.transform = CATransform3DIdentity
|
||||
snapshot.layer.shadowOpacity = 0
|
||||
|
||||
// Animate to lifted state
|
||||
UIView.animate(
|
||||
withDuration: 0.15, // Quick lift per CONTEXT.md
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 0.85,
|
||||
initialSpringVelocity: 0.5
|
||||
) {
|
||||
let scale: CGFloat = 1.025
|
||||
let tiltAngle: CGFloat = 2.0 * .pi / 180.0
|
||||
|
||||
var transform = CATransform3DIdentity
|
||||
transform.m34 = -1.0 / 500.0
|
||||
transform = CATransform3DScale(transform, scale, scale, 1.0)
|
||||
transform = CATransform3DRotate(transform, tiltAngle, 0, 1, 0)
|
||||
|
||||
snapshot.layer.transform = transform
|
||||
snapshot.layer.shadowOpacity = 0.25
|
||||
}
|
||||
|
||||
// Hide original cell during drag
|
||||
cell.alpha = 0
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Custom Insertion Line
|
||||
|
||||
**What:** Themed insertion line between items showing drop target
|
||||
**When to use:** DRAG-02 (insertion line)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
|
||||
/// Custom insertion line view
|
||||
private class InsertionLineView: UIView {
|
||||
|
||||
private let lineLayer = CAShapeLayer()
|
||||
|
||||
var themeColor: UIColor = .systemOrange {
|
||||
didSet { lineLayer.strokeColor = themeColor.cgColor }
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setup()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setup()
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
// Line properties per CONTEXT.md: plain line, 2-4pt thickness
|
||||
lineLayer.strokeColor = themeColor.cgColor
|
||||
lineLayer.lineWidth = 3.0
|
||||
lineLayer.lineCap = .round
|
||||
layer.addSublayer(lineLayer)
|
||||
|
||||
// Start hidden
|
||||
alpha = 0
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// Draw horizontal line
|
||||
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
|
||||
}
|
||||
|
||||
/// Fade in animation (~150ms per CONTEXT.md)
|
||||
func fadeIn() {
|
||||
UIView.animate(withDuration: 0.15) {
|
||||
self.alpha = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade out animation
|
||||
func fadeOut() {
|
||||
UIView.animate(withDuration: 0.15) {
|
||||
self.alpha = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In ItineraryTableViewController:
|
||||
|
||||
private var insertionLine: InsertionLineView?
|
||||
|
||||
private func showInsertionLine(at indexPath: IndexPath) {
|
||||
// Create if needed
|
||||
if insertionLine == nil {
|
||||
insertionLine = InsertionLineView()
|
||||
insertionLine?.themeColor = Theme.warmOrange // Theme-aware per CONTEXT.md
|
||||
tableView.addSubview(insertionLine!)
|
||||
}
|
||||
|
||||
// Position between rows
|
||||
let rect = tableView.rectForRow(at: indexPath)
|
||||
insertionLine?.frame = CGRect(
|
||||
x: 0,
|
||||
y: rect.minY - 2, // Position above target row
|
||||
width: tableView.bounds.width,
|
||||
height: 4
|
||||
)
|
||||
|
||||
insertionLine?.fadeIn()
|
||||
}
|
||||
|
||||
private func hideInsertionLine() {
|
||||
insertionLine?.fadeOut()
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Item Shuffle Animation
|
||||
|
||||
**What:** Items move out of the way during drag
|
||||
**When to use:** DRAG-03 (100ms shuffle animation)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
|
||||
// Note: UITableView automatically animates row shuffling when using
|
||||
// UITableViewDropDelegate with .insertAtDestinationIndexPath intent.
|
||||
// The animation duration can be customized via:
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidUpdate session: UIDropSession,
|
||||
withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
|
||||
|
||||
// Per CONTEXT.md: Immediate shuffle, items move instantly
|
||||
// The system handles this, but we can influence timing via
|
||||
// UITableView.performBatchUpdates for more control if needed
|
||||
|
||||
// For custom shuffle timing (100ms per DRAG-03):
|
||||
UIView.animate(withDuration: 0.1) { // 100ms
|
||||
tableView.performBatchUpdates(nil)
|
||||
}
|
||||
|
||||
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Invalid Drop Rejection with Snap-Back
|
||||
|
||||
**What:** Invalid drops rejected with spring animation back to origin
|
||||
**When to use:** DRAG-05 (invalid drop rejection)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
|
||||
/// Handles invalid drop with snap-back animation
|
||||
private func performSnapBack(for draggedSnapshot: UIView, to originalFrame: CGRect) {
|
||||
// Per CONTEXT.md: ~200ms with slight overshoot
|
||||
UIView.animate(
|
||||
withDuration: 0.2,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 0.7, // Slight overshoot
|
||||
initialSpringVelocity: 0.3
|
||||
) {
|
||||
// Reset transform
|
||||
draggedSnapshot.layer.transform = CATransform3DIdentity
|
||||
draggedSnapshot.frame = originalFrame
|
||||
draggedSnapshot.layer.shadowOpacity = 0
|
||||
} completion: { _ in
|
||||
draggedSnapshot.removeFromSuperview()
|
||||
}
|
||||
|
||||
// Error haptic per CONTEXT.md: 3 quick taps
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(.error)
|
||||
|
||||
// Additional taps (custom pattern)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
/// Red tint on dragged item over invalid zone
|
||||
private func applyInvalidTint(to snapshot: UIView) {
|
||||
// Per CONTEXT.md: Red tint while hovering over invalid zone
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
snapshot.layer.backgroundColor = UIColor.systemRed.withAlphaComponent(0.15).cgColor
|
||||
}
|
||||
}
|
||||
|
||||
private func removeInvalidTint(from snapshot: UIView) {
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
snapshot.layer.backgroundColor = UIColor.clear.cgColor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Haptic Feedback Integration
|
||||
|
||||
**What:** Tactile feedback at key interaction points
|
||||
**When to use:** DRAG-06 (haptic on grab/drop)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation based on Hacking with Swift patterns
|
||||
|
||||
final class HapticManager {
|
||||
|
||||
// Pre-created generators for reduced latency
|
||||
private let impactLight = UIImpactFeedbackGenerator(style: .light)
|
||||
private let impactMedium = UIImpactFeedbackGenerator(style: .medium)
|
||||
private let notification = UINotificationFeedbackGenerator()
|
||||
private let selection = UISelectionFeedbackGenerator()
|
||||
|
||||
func prepare() {
|
||||
impactLight.prepare()
|
||||
impactMedium.prepare()
|
||||
notification.prepare()
|
||||
}
|
||||
|
||||
/// Light impact on grab (per DRAG-06)
|
||||
func grab() {
|
||||
impactLight.impactOccurred()
|
||||
}
|
||||
|
||||
/// Medium impact on drop (per DRAG-06)
|
||||
func drop() {
|
||||
impactMedium.impactOccurred()
|
||||
}
|
||||
|
||||
/// Error pattern for invalid drop (3 quick taps per CONTEXT.md)
|
||||
func errorTripleTap() {
|
||||
notification.notificationOccurred(.error)
|
||||
|
||||
// Additional taps for "nope" feeling
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in
|
||||
self?.impactLight.impactOccurred()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
self?.impactLight.impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtle feedback when crossing zone boundaries
|
||||
func zoneCrossing() {
|
||||
selection.selectionChanged()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 7: Auto-Scroll During Drag
|
||||
|
||||
**What:** Table view scrolls when dragging near edges
|
||||
**When to use:** DRAG-07 (auto-scroll at viewport edge)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
|
||||
// Note: UITableView with UIDropDelegate provides automatic scrolling
|
||||
// when the drag position nears the top or bottom edges. This is the
|
||||
// default behavior - no custom code needed.
|
||||
|
||||
// However, if you need custom scroll speed or dead zones:
|
||||
|
||||
private var autoScrollTimer: Timer?
|
||||
private let scrollSpeed: CGFloat = 5.0 // Points per tick
|
||||
private let deadZoneHeight: CGFloat = 60.0 // Distance from edge to trigger
|
||||
|
||||
private func updateAutoScroll(for dragLocation: CGPoint) {
|
||||
let bounds = tableView.bounds
|
||||
|
||||
if dragLocation.y < deadZoneHeight {
|
||||
// Near top - scroll up
|
||||
startAutoScroll(direction: -1)
|
||||
} else if dragLocation.y > bounds.height - deadZoneHeight {
|
||||
// Near bottom - scroll down
|
||||
startAutoScroll(direction: 1)
|
||||
} else {
|
||||
stopAutoScroll()
|
||||
}
|
||||
}
|
||||
|
||||
private func startAutoScroll(direction: Int) {
|
||||
guard autoScrollTimer == nil else { return }
|
||||
|
||||
autoScrollTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
var offset = self.tableView.contentOffset
|
||||
offset.y += CGFloat(direction) * self.scrollSpeed
|
||||
|
||||
// Clamp to bounds
|
||||
offset.y = max(0, min(offset.y, self.tableView.contentSize.height - self.tableView.bounds.height))
|
||||
|
||||
self.tableView.setContentOffset(offset, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopAutoScroll() {
|
||||
autoScrollTimer?.invalidate()
|
||||
autoScrollTimer = nil
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Mixing legacy and modern APIs:** Don't implement both `canMoveRowAt` AND `itemsForBeginning`. Choose one approach.
|
||||
- **Blocking main thread in drop handler:** Constraint validation and data updates should be fast. No async operations in `performDropWith`.
|
||||
- **Over-haptic:** Per CONTEXT.md, no sound effects. Keep haptic patterns subtle (light grab, medium drop, error pattern only on failure).
|
||||
- **Custom drag handle views:** Let UITableView manage drag handles. Custom handles break accessibility.
|
||||
- **Ignoring `dragInteractionEnabled`:** On iPhone, this defaults to `false`. Must explicitly set `true`.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Drag preview snapshot | Manual layer rendering | `cell.snapshotView(afterScreenUpdates:)` | Handles cell configuration automatically |
|
||||
| Row reordering animation | Manual frame manipulation | `UITableViewDropCoordinator.drop(toRowAt:)` | System handles animation timing |
|
||||
| Auto-scroll | Custom scroll timers | Built-in drop delegate behavior | System handles edge detection automatically |
|
||||
| Insertion indicator | Custom CAShapeLayer in tableView | Custom view added as subview | Easier positioning, theme support |
|
||||
| Haptic patterns | AVAudioEngine vibration | UIFeedbackGenerator subclasses | Battery efficient, system-consistent |
|
||||
|
||||
**Key insight:** UITableViewDropDelegate with `.insertAtDestinationIndexPath` intent provides most of the shuffle and insertion behavior automatically. Custom work is only needed for the visual styling (custom insertion line, tilt transform).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Drag Handle Not Appearing
|
||||
|
||||
**What goes wrong:** Drag handles don't show on reorderable cells
|
||||
**Why it happens:** `dragInteractionEnabled` is false by default on iPhone
|
||||
**How to avoid:** Explicitly set `tableView.dragInteractionEnabled = true`
|
||||
**Warning signs:** Drag works in edit mode but not with modern delegates
|
||||
|
||||
### Pitfall 2: Drop Proposal Called Too Frequently
|
||||
|
||||
**What goes wrong:** Performance issues during drag
|
||||
**Why it happens:** `dropSessionDidUpdate` called on every touch move
|
||||
**How to avoid:** Cache validation results, avoid expensive calculations
|
||||
**Warning signs:** Janky drag animation, dropped frames
|
||||
|
||||
### Pitfall 3: Transform Conflicts
|
||||
|
||||
**What goes wrong:** Scale/tilt animation looks wrong
|
||||
**Why it happens:** CATransform3D operations are order-dependent
|
||||
**How to avoid:** Apply transforms in consistent order: perspective (m34), then scale, then rotate
|
||||
**Warning signs:** Unexpected skew, item flips unexpectedly
|
||||
|
||||
### Pitfall 4: Snapshot View Clipping Shadow
|
||||
|
||||
**What goes wrong:** Shadow gets cut off on drag preview
|
||||
**Why it happens:** `masksToBounds = true` or parent clips
|
||||
**How to avoid:** Set `masksToBounds = false` on snapshot, add padding to frame
|
||||
**Warning signs:** Shadow appears clipped or box-shaped
|
||||
|
||||
### Pitfall 5: Insertion Line Z-Order
|
||||
|
||||
**What goes wrong:** Insertion line appears behind cells
|
||||
**Why it happens:** Added to tableView at wrong sublayer position
|
||||
**How to avoid:** Use `tableView.addSubview()` and ensure it's above cells, or add to `tableView.superview`
|
||||
**Warning signs:** Line visible only in gaps between cells
|
||||
|
||||
### Pitfall 6: Haptic Latency
|
||||
|
||||
**What goes wrong:** Haptic feels delayed from grab action
|
||||
**Why it happens:** Generator not prepared
|
||||
**How to avoid:** Call `prepare()` on feedback generators before expected interaction
|
||||
**Warning signs:** 50-100ms delay between touch and haptic
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Complete Drag Session Lifecycle
|
||||
|
||||
```swift
|
||||
// Source: Recommended implementation combining all patterns
|
||||
|
||||
extension ItineraryTableViewController: UITableViewDragDelegate, UITableViewDropDelegate {
|
||||
|
||||
// MARK: - Drag Delegate
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
itemsForBeginning session: UIDragSession,
|
||||
at indexPath: IndexPath) -> [UIDragItem] {
|
||||
|
||||
let item = flatItems[indexPath.row]
|
||||
guard item.isReorderable else { return [] }
|
||||
|
||||
// Store context
|
||||
let context = DragContext(
|
||||
item: item,
|
||||
sourceIndexPath: indexPath,
|
||||
originalFrame: tableView.rectForRow(at: indexPath)
|
||||
)
|
||||
session.localContext = context
|
||||
|
||||
// Prepare haptic
|
||||
hapticManager.prepare()
|
||||
|
||||
// Create drag item
|
||||
let provider = NSItemProvider(object: item.id as NSString)
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
|
||||
|
||||
guard let cell = tableView.cellForRow(at: indexPath) else { return nil }
|
||||
|
||||
let params = UIDragPreviewParameters()
|
||||
let rect = cell.contentView.bounds.insetBy(dx: 4, dy: 2)
|
||||
params.visiblePath = UIBezierPath(roundedRect: rect, cornerRadius: 12)
|
||||
return params
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dragSessionWillBegin session: UIDragSession) {
|
||||
|
||||
hapticManager.grab()
|
||||
|
||||
// Create custom snapshot with lift animation
|
||||
if let context = session.localContext as? DragContext,
|
||||
let cell = tableView.cellForRow(at: context.sourceIndexPath) {
|
||||
|
||||
let snapshot = createLiftedSnapshot(for: cell)
|
||||
tableView.superview?.addSubview(snapshot)
|
||||
animateLift(for: cell, snapshot: snapshot)
|
||||
|
||||
context.snapshot = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drop Delegate
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidUpdate session: UIDropSession,
|
||||
withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
|
||||
|
||||
guard let context = session.localDragSession?.localContext as? DragContext,
|
||||
let destIndex = destinationIndexPath else {
|
||||
hideInsertionLine()
|
||||
return UITableViewDropProposal(operation: .forbidden)
|
||||
}
|
||||
|
||||
// Validate using existing ItineraryConstraints
|
||||
let isValid = validateDropPosition(
|
||||
item: context.item,
|
||||
at: destIndex
|
||||
)
|
||||
|
||||
if isValid {
|
||||
showInsertionLine(at: destIndex)
|
||||
context.snapshot.map { removeInvalidTint(from: $0) }
|
||||
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
|
||||
} else {
|
||||
hideInsertionLine()
|
||||
context.snapshot.map { applyInvalidTint(to: $0) }
|
||||
return UITableViewDropProposal(operation: .forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
performDropWith coordinator: UITableViewDropCoordinator) {
|
||||
|
||||
guard let item = coordinator.items.first,
|
||||
let source = item.sourceIndexPath,
|
||||
let dest = coordinator.destinationIndexPath,
|
||||
let context = coordinator.session.localDragSession?.localContext as? DragContext else {
|
||||
|
||||
// Invalid drop - snap back
|
||||
if let context = coordinator.session.localDragSession?.localContext as? DragContext {
|
||||
performSnapBack(for: context.snapshot!, to: context.originalFrame)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hideInsertionLine()
|
||||
|
||||
// Animate drop
|
||||
coordinator.drop(item.dragItem, toRowAt: dest)
|
||||
|
||||
// Update data (existing logic)
|
||||
let rowItem = flatItems[source.row]
|
||||
flatItems.remove(at: source.row)
|
||||
flatItems.insert(rowItem, at: dest.row)
|
||||
|
||||
// Haptic
|
||||
hapticManager.drop()
|
||||
|
||||
// Remove snapshot
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
context.snapshot?.alpha = 0
|
||||
} completion: { _ in
|
||||
context.snapshot?.removeFromSuperview()
|
||||
}
|
||||
|
||||
// Restore original cell
|
||||
if let cell = tableView.cellForRow(at: dest) {
|
||||
cell.alpha = 1
|
||||
}
|
||||
|
||||
// Notify callbacks (existing logic)
|
||||
// ...
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidEnd session: UIDropSession) {
|
||||
|
||||
hideInsertionLine()
|
||||
stopAutoScroll()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Constraint Integration with Existing ItineraryConstraints
|
||||
|
||||
```swift
|
||||
// Source: Bridges existing Phase 2 constraints to drop validation
|
||||
|
||||
private func validateDropPosition(item: ItineraryRowItem, at indexPath: IndexPath) -> Bool {
|
||||
let day = dayNumber(forRow: indexPath.row)
|
||||
let sortOrder = calculateSortOrder(at: indexPath.row)
|
||||
|
||||
switch item {
|
||||
case .travel(let segment, _):
|
||||
// Get travel item for constraint check
|
||||
guard let travelItem = findItineraryItem(for: segment),
|
||||
let constraints = constraints else {
|
||||
return false
|
||||
}
|
||||
return constraints.isValidPosition(for: travelItem, day: day, sortOrder: sortOrder)
|
||||
|
||||
case .customItem(let customItem):
|
||||
// Custom items have no constraints per Phase 2
|
||||
return constraints?.isValidPosition(for: customItem, day: day, sortOrder: sortOrder) ?? true
|
||||
|
||||
default:
|
||||
return false // Games/headers can't be dropped
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `canMoveRowAt`/`moveRowAt` | `UITableViewDragDelegate`/`UITableViewDropDelegate` | iOS 11 (2017) | Custom previews, better control |
|
||||
| Haptic via AudioServices | UIFeedbackGenerator | iOS 10 (2016) | Battery efficient, system-consistent |
|
||||
| Manual reorder animation | `UITableViewDropCoordinator.drop(toRowAt:)` | iOS 11 (2017) | System-managed smooth animation |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `UILongPressGestureRecognizer` for drag initiation (use delegate methods instead)
|
||||
- `moveRow(at:to:)` during active drag (use batch updates or coordinator)
|
||||
|
||||
**iOS 26 Considerations:**
|
||||
- No breaking changes found to UITableView drag-drop APIs
|
||||
- `CLGeocoder` deprecated (not relevant to drag-drop)
|
||||
- Swift 6 concurrency applies; ensure delegate methods don't capture vars incorrectly
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved by Research
|
||||
|
||||
1. **How to customize insertion line appearance?**
|
||||
- Answer: Add custom `UIView` subview to tableView, position at `rectForRow(at:).minY`
|
||||
- Confidence: HIGH (standard UIKit pattern)
|
||||
|
||||
2. **How to apply tilt transform during drag?**
|
||||
- Answer: Use `CATransform3D` with m34 perspective, then rotate around Y axis
|
||||
- Confidence: HIGH (verified with Core Animation docs)
|
||||
|
||||
3. **Does auto-scroll work automatically?**
|
||||
- Answer: YES, UITableViewDropDelegate provides automatic edge scrolling
|
||||
- Confidence: HIGH (verified in WWDC session)
|
||||
|
||||
4. **How to integrate with existing constraint validation?**
|
||||
- Answer: Use `dropSessionDidUpdate` to call `ItineraryConstraints.isValidPosition()`
|
||||
- Confidence: HIGH (existing code already has this structure)
|
||||
|
||||
### Claude's Discretion per CONTEXT.md
|
||||
|
||||
1. **Insertion line thickness?**
|
||||
- Recommendation: 3pt (middle of 2-4pt range, visible but not chunky)
|
||||
|
||||
2. **Shadow depth during drag?**
|
||||
- Recommendation: offset (0, 8), radius 16, opacity 0.25 (matches iOS style)
|
||||
|
||||
3. **Drop settle animation timing?**
|
||||
- Recommendation: 0.2s with spring damping 0.8 (matches iOS system feel)
|
||||
|
||||
4. **Auto-scroll speed and dead zone?**
|
||||
- Recommendation: Use system default. If custom needed: 60pt dead zone, 5pt/tick scroll speed
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [Apple WWDC 2017 Session 223 - Drag and Drop with Collection and Table View](https://asciiwwdc.com/2017/sessions/223) - Authoritative delegate documentation
|
||||
- [Hacking with Swift - How to generate haptic feedback](https://www.hackingwithswift.com/example-code/uikit/how-to-generate-haptic-feedback-with-uifeedbackgenerator) - UIFeedbackGenerator patterns
|
||||
- [Hacking with Swift - How to add drag and drop to your app](https://www.hackingwithswift.com/example-code/uikit/how-to-add-drag-and-drop-to-your-app) - UITableView delegate examples
|
||||
- Codebase: `ItineraryTableViewController.swift` - Current implementation to enhance
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Swiftjective-C - Using UIDragPreview to Customize Drag Items](https://swiftjectivec.com/Use-Preview-Parameters-To-Customize-Drag-Items/) - Preview customization
|
||||
- [RDerik - Using Drag and Drop on UITableView for reorder](https://rderik.com/blog/using-drag-and-drop-on-uitableview-for-reorder/) - Complete implementation example
|
||||
- [Josh Spadd - UIViewRepresentable with Delegates](https://www.joshspadd.com/2024/01/swiftui-view-representable-delegates/) - Coordinator pattern
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- General CATransform3D knowledge from training data (verify m34 value experimentally)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Delegate API structure: HIGH - Official Apple sources, verified in WWDC
|
||||
- Transform/animation patterns: HIGH - Standard Core Animation
|
||||
- Haptic integration: HIGH - Well-documented UIKit API
|
||||
- Custom insertion line: MEDIUM - Pattern is standard but exact positioning may need tuning
|
||||
- iOS 26 compatibility: MEDIUM - No breaking changes found but not explicitly verified
|
||||
|
||||
**Research date:** 2026-01-18
|
||||
**Valid until:** 2026-04-18 (3 months - APIs are stable)
|
||||
|
||||
## Recommendations for Planning
|
||||
|
||||
### Phase 4 Scope
|
||||
|
||||
1. **Migrate to modern drag-drop delegates**
|
||||
- Replace `canMoveRowAt`/`moveRowAt` with `UITableViewDragDelegate`/`UITableViewDropDelegate`
|
||||
- Maintain backward compatibility with existing constraint validation
|
||||
|
||||
2. **Implement custom drag preview**
|
||||
- Scale (1.02-1.03x), shadow, tilt (2-3 degrees)
|
||||
- Quick lift animation (~150ms spring)
|
||||
|
||||
3. **Add themed insertion line**
|
||||
- Custom UIView positioned between rows
|
||||
- Follow theme color, fade in/out (150ms)
|
||||
|
||||
4. **Integrate haptic feedback**
|
||||
- Light impact on grab
|
||||
- Medium impact on successful drop
|
||||
- Triple-tap error pattern on invalid drop
|
||||
|
||||
5. **Handle invalid drops**
|
||||
- Red tint overlay on dragged item
|
||||
- Spring snap-back animation (200ms with overshoot)
|
||||
|
||||
### What NOT to Build
|
||||
|
||||
- Custom auto-scroll (use system default)
|
||||
- Sound effects (per CONTEXT.md decision)
|
||||
- New constraint validation logic (Phase 2 complete)
|
||||
- New flattening logic (Phase 3 complete)
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Phase 2: `ItineraryConstraints` for `isValidPosition()` (complete)
|
||||
- Phase 3: `ItineraryFlattener` for `calculateSortOrder()` (complete)
|
||||
- Settings: Theme color for insertion line (existing)
|
||||
Reference in New Issue
Block a user