Files
SportstimeAPI/.planning/phases/04-drag-interaction/04-RESEARCH.md
Trey t 812c7e631d 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>
2026-01-18 16:34:41 -06:00

32 KiB

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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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

// 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

// 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)

Secondary (MEDIUM confidence)

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)