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>
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
canMoveRowAtANDitemsForBeginning. 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 tofalse. Must explicitly settrue.
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:
UILongPressGestureRecognizerfor 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
CLGeocoderdeprecated (not relevant to drag-drop)- Swift 6 concurrency applies; ensure delegate methods don't capture vars incorrectly
Open Questions
Resolved by Research
-
How to customize insertion line appearance?
- Answer: Add custom
UIViewsubview to tableView, position atrectForRow(at:).minY - Confidence: HIGH (standard UIKit pattern)
- Answer: Add custom
-
How to apply tilt transform during drag?
- Answer: Use
CATransform3Dwith m34 perspective, then rotate around Y axis - Confidence: HIGH (verified with Core Animation docs)
- Answer: Use
-
Does auto-scroll work automatically?
- Answer: YES, UITableViewDropDelegate provides automatic edge scrolling
- Confidence: HIGH (verified in WWDC session)
-
How to integrate with existing constraint validation?
- Answer: Use
dropSessionDidUpdateto callItineraryConstraints.isValidPosition() - Confidence: HIGH (existing code already has this structure)
- Answer: Use
Claude's Discretion per CONTEXT.md
-
Insertion line thickness?
- Recommendation: 3pt (middle of 2-4pt range, visible but not chunky)
-
Shadow depth during drag?
- Recommendation: offset (0, 8), radius 16, opacity 0.25 (matches iOS style)
-
Drop settle animation timing?
- Recommendation: 0.2s with spring damping 0.8 (matches iOS system feel)
-
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 - Authoritative delegate documentation
- Hacking with Swift - How to generate haptic feedback - UIFeedbackGenerator patterns
- Hacking with Swift - 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 - Preview customization
- RDerik - Using Drag and Drop on UITableView for reorder - Complete implementation example
- Josh Spadd - UIViewRepresentable with 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
-
Migrate to modern drag-drop delegates
- Replace
canMoveRowAt/moveRowAtwithUITableViewDragDelegate/UITableViewDropDelegate - Maintain backward compatibility with existing constraint validation
- Replace
-
Implement custom drag preview
- Scale (1.02-1.03x), shadow, tilt (2-3 degrees)
- Quick lift animation (~150ms spring)
-
Add themed insertion line
- Custom UIView positioned between rows
- Follow theme color, fade in/out (150ms)
-
Integrate haptic feedback
- Light impact on grab
- Medium impact on successful drop
- Triple-tap error pattern on invalid drop
-
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:
ItineraryConstraintsforisValidPosition()(complete) - Phase 3:
ItineraryFlattenerforcalculateSortOrder()(complete) - Settings: Theme color for insertion line (existing)