Files: - STACK.md - UITableView drag-drop APIs, SwiftUI bridging patterns - FEATURES.md - Table stakes UX (lift, insertion line, haptics), polish features - ARCHITECTURE.md - 5-layer semantic positioning architecture - PITFALLS.md - Critical pitfalls (row vs semantic, travel as structural) - SUMMARY.md - Executive synthesis with roadmap implications Key findings: - Stack: UITableView + UIHostingConfiguration (existing pattern validated) - Architecture: Semantic (day, sortOrder) model, not row indices - Critical pitfall: Row indices are ephemeral; semantic positions are truth Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
16 KiB
Pitfalls Research: UITableView Drag-Drop with Semantic Positioning
Domain: iOS drag-drop reordering with constrained semantic positions (day + sortOrder) Researched: 2026-01-18 Context: SportsTime itinerary editor - trip items constrained by game schedules
Critical Pitfalls
1. Row Index vs Semantic Position Confusion
What goes wrong: Code treats UITableView row indices as the source of truth instead of semantic positions (day, sortOrder). When the table flattens hierarchical data, row indices become disconnected from business logic.
Why it happens: UITableView's moveRowAt:to: gives you row indices. It's tempting to translate row → position directly. But flattening destroys the semantic relationship.
Consequences:
- Items appear in wrong positions after reload
- Constraints calculated against stale row indices
- Save/load round-trip loses item positions
- Drag logic and reload logic fight each other (observed in previous attempts)
Prevention:
- Define canonical semantic model:
(day: Int, sortOrder: Double)per item - Row indices are DISPLAY concerns only - never persist them
- All constraint validation operates on semantic positions, not rows
- After drop, immediately calculate semantic position, discard row index
Detection (Warning Signs):
- Code stores row indices anywhere except during drag
- Constraint checks reference
indexPath.rowinstead ofitem.day/item.sortOrder - Test passes with static data but fails after reload
Phase to Address: Phase 1 (data model design) - get this wrong and everything else breaks
2. Treating Travel as Structural Instead of Positional
What goes wrong: Travel segments treated as "travelBefore" (attached to a day) instead of independent positioned items.
Why it happens: It's intuitive to think "travel happens before Day 3" rather than "travel is an item with day=3, sortOrder=-1.5". The former creates tight coupling.
Consequences:
- Can't position travel AFTER games on the same day (morning arrival vs evening arrival)
- Reordering travel requires updating the day's structural property, not just the item
- Travel placement logic diverges from custom item logic (code duplication)
- Hard to represent "travel morning of game day" vs "travel after last game"
Prevention:
- Travel is an item with
kind: .travel, not a day property - Use
sortOrder < 0convention for "before games",sortOrder >= 0for "after games" - Travel follows same drag/drop code path as custom items (with additional constraints)
- Store travel position the same way as other items:
(day, sortOrder)
Detection (Warning Signs):
- Data model has
travelBeforeortravelDayas a day property - Different code paths for moving travel vs moving custom items
- Can't drop travel between two games on the same day
Phase to Address: Phase 1 (data model) - defines how travel is represented
3. Hard-Coded Flatten Order That Ignores sortOrder
What goes wrong: Flattening algorithm builds rows in a fixed order (header, travel, games, custom items) and ignores actual sortOrder values.
Why it happens: Initial implementation works without sortOrder, so it gets hard-coded. Then sortOrder is added for persistence but flatten logic isn't updated.
Consequences:
- Items render in wrong order even though sortOrder is correct in data
- Drag works during session but positions reset after view reload
- Tests pass for initial render, fail for reload scenarios
Prevention:
- Flatten algorithm MUST sort by
sortOrderwithin each day - Use
sortOrder < 0convention to place items before games,sortOrder >= 0after - Write test: "items render in sortOrder order after reload"
- Single source of truth:
flatItems = items.sorted(by: { $0.sortOrder < $1.sortOrder })
Detection (Warning Signs):
- Flatten code has
if .travel { append }followed byif .games { append }without sorting - Items snap to different positions after view reload
- Manual reordering works but persistence loses order
Phase to Address: Phase 2 (view implementation) - flattening is the bridge from model to display
4. Data Model Out of Sync During Drag
What goes wrong: UITableView's visual state diverges from data model during drag, causing NSInternalInconsistencyException crashes.
Why it happens: UITableView manages its own internal row state during drag. If you call reloadData() or performBatchUpdates() while dragging, the table's internal state conflicts with yours.
Consequences:
- Crash: "attempt to delete row X from section Y which only contains Z rows"
- Crash: "Invalid update: invalid number of rows in section"
- Visual glitches where rows jump or disappear
Prevention:
- Never call
reloadData()during active drag - Update data model in
moveRowAt:to:completion (after UITableView has settled) - Guard SwiftUI updates that would trigger re-render during drag
- Use
draggingItem != nilflag to skip external data updates
Detection (Warning Signs):
- Crashes during drag (not on drop)
- SwiftUI parent triggers updates that propagate to UIKit during drag
performBatchUpdatescalled from background thread
Phase to Address: Phase 2 (view implementation) - UIKit/SwiftUI bridging requires explicit guards
5. reloadData vs performBatchUpdates During Reorder
What goes wrong: Using reloadData() after drop causes flickering, scroll position reset, and lost drag handle state.
Why it happens: reloadData() is the simple approach, but it destroys all cell state. During reordering, this fights with UIKit's internal animations.
Consequences:
- Flickering after drop (entire table redraws)
- Scroll position jumps to top
- Cell selection state lost
- No smooth animation for settled items
Prevention:
- After drop, update
flatItemsin place (remove/insert) - Let UITableView's internal move animation complete naturally
- Only call
reloadData()for external data changes (not user reorder) - For external changes during editing, batch updates or defer until drag ends
Detection (Warning Signs):
- Visible flicker after dropping an item
- Scroll position resets after reorder
- Debug logs show
reloadDatacalled inmoveRowAt:to:
Phase to Address: Phase 2 (view implementation)
6. Coordinate Space Confusion in targetIndexPath
What goes wrong: targetIndexPathForMoveFromRowAt:toProposedIndexPath: operates in "proposed" coordinate space where the source row is conceptually removed. Code assumes original coordinates.
Why it happens: UITableView's coordinate system during drag is subtle. "Proposed destination row 5" means "row 5 after removing the source". If source was row 2, the original array's row 5 is now row 4.
Consequences:
- Constraints validated against wrong row
- Items snap to unexpected positions
- Off-by-one errors in constraint checking
Prevention:
- Understand UIKit's drag semantics: destination is in "post-removal" space
- When validating constraints, simulate the move first
- Pre-compute valid destination rows in proposed coordinate space at drag start
- Use helper:
simulateMove(original:, sourceRow:, destinationProposedRow:)
Detection (Warning Signs):
- Constraint validation works for some drags, fails for others
- Off-by-one errors when source is above/below destination
- Tests pass when source is first row, fail otherwise
Phase to Address: Phase 2 (constraint validation during drag)
Subtle Pitfalls
7. iPhone vs iPad Drag Interaction Defaults
What goes wrong: Drag works on iPad but not iPhone because dragInteractionEnabled defaults to false on iPhone.
Why it happens: iPad has split-screen multitasking where drag-drop is common. iPhone doesn't, so Apple disabled it by default.
Prevention:
tableView.dragInteractionEnabled = true // Required for iPhone
Detection: Drag handle visible but nothing happens when dragging on iPhone
Phase to Address: Phase 2 (initial setup)
8. NSItemProvider Without Object Breaks Mac Catalyst
What goes wrong: Drag works on iOS but drop handlers never fire on Mac Catalyst.
Why it happens: Mac Catalyst has stricter requirements. An NSItemProvider constructed without an object causes silent failures even if localObject is set.
Prevention:
// Wrong: NSItemProvider with localObject only
let provider = NSItemProvider()
provider.suggestLocalObject(item) // Breaks Catalyst
// Right: NSItemProvider with actual object
let provider = NSItemProvider(object: item as NSItemProviderWriting)
Detection: Works on iOS simulator, fails on Mac Catalyst
Phase to Address: Phase 2 (if supporting Mac Catalyst)
9. Nil Destination Index Path on Whitespace Drop
What goes wrong: User drops item on empty area of table, destinationIndexPath is nil, app crashes or behaves unexpectedly.
Why it happens: dropSessionDidUpdate and performDropWith receive nil destination when dropping over areas without cells.
Prevention:
let destinationIndexPath: IndexPath
if let indexPath = coordinator.destinationIndexPath {
destinationIndexPath = indexPath
} else {
// Drop on whitespace: append to last section
let section = tableView.numberOfSections - 1
let row = tableView.numberOfRows(inSection: section)
destinationIndexPath = IndexPath(row: row, section: section)
}
Detection: Crash when dropping below last visible row
Phase to Address: Phase 2 (drop handling)
10. sortOrder Precision Exhaustion
What goes wrong: After many insertions between items, midpoint algorithm produces values too close together to distinguish.
Why it happens: Repeatedly inserting between two values (1.0 and 2.0 -> 1.5 -> 1.25 -> 1.125...) eventually exhausts Double precision.
Consequences:
- Items with "equal" sortOrder render in undefined order
- Reorder appears to work but fails on reload
Prevention:
- Double has ~15 significant digits - sufficient for ~50 midpoint insertions
- For extreme cases, implement "normalize" function that resets to 1.0, 2.0, 3.0...
- Monitor: if
abs(a.sortOrder - b.sortOrder) < 1e-10, trigger normalize
Detection: Items render in wrong order despite correct sortOrder values
Phase to Address: Phase 3 (long-term maintenance) - unlikely in normal use
11. Missing Section Header Handling
What goes wrong: Day headers (section markers) treated as drop targets, items get "stuck" at day boundaries.
Why it happens: If day headers are regular rows, nothing stops items from being dropped ON them instead of after them.
Prevention:
- Day headers are non-reorderable (
canMoveRowAtreturns false) targetIndexPathForMoveFromRowAtredirects drops ON headers to AFTER headers- Or use actual UITableView sections with headers (more complex)
Detection: Items can be dragged onto day header rows
Phase to Address: Phase 2 (drop target validation)
12. SwiftUI Update Loop with UIHostingConfiguration
What goes wrong: UIHostingConfiguration cell causes infinite layout/update loops.
Why it happens: SwiftUI state change -> cell update -> triggers UITableView layout -> triggers another SwiftUI update.
Prevention:
- Track header height changes with threshold (
abs(new - old) > 1.0) - Use
isAdjustingHeaderguard to prevent re-entrant updates - Don't pass changing state through UIHostingConfiguration during drag
Detection: App freezes or CPU spins during table interaction
Phase to Address: Phase 2 (UIKit/SwiftUI bridging)
Previous Failures (Addressed)
Based on the stated previous failures, here's how to address each:
"Row-based snapping instead of semantic (day, sortOrder)"
Root Cause: Using row indices as positions
Fix: Define ItineraryItem with day: Int and sortOrder: Double. All position logic operates on these fields, never row indices. Row indices are ephemeral display concerns.
"Treating travel as structural ('travelBefore') instead of positional"
Root Cause: Travel was a day property, not an item
Fix: Travel is an ItineraryItem with kind: .travel(TravelInfo). It has its own day and sortOrder like any other item. Use sortOrder < 0 for "before games" convention.
"Losing sortOrder during flattening"
Root Cause: Flatten algorithm ignored sortOrder, used hard-coded order
Fix: Flatten sorts items by sortOrder within each day. Write test: "after drop and reload, items appear in same order".
"Hard-coded flatten order that ignored sortOrder"
Root Cause: Same as above - flatten was header, travel, games, custom without sorting
Fix: Split items into beforeGames (sortOrder < 0) and afterGames (sortOrder >= 0), sort each group by sortOrder, then assemble: header -> beforeGames -> games -> afterGames.
"Drag logic and reload logic fighting each other"
Root Cause: SwiftUI parent triggered reloads during UIKit drag Fix:
draggingItem != nilflag guards against external updates- Never call
reloadData()inmoveRowAt:to: - Use completion handler or end-drag callback for state sync
Warning Signs Checklist
Use this during implementation to catch problems early:
Data Model Red Flags
- Row indices stored anywhere except during active drag
travelDayortravelBeforeas a day property- No
sortOrderfield on reorderable items - Different data structures for travel vs custom items
Flatten/Display Red Flags
- Hard-coded render order that doesn't reference sortOrder
- Items render correctly initially but wrong after reload
- Constraint checks use row indices instead of semantic positions
Drag Interaction Red Flags
- Crashes during drag (before drop completes)
- Flickering or scroll jump after drop
- Works on iPad but not iPhone
- Works in simulator but not Mac Catalyst
Persistence Red Flags
- Items change position after save/load cycle
- Debug logs show mismatched positions before/after reload
- Tests pass for single operation but fail for sequences
Phase Mapping
| Pitfall | Phase to Address | Risk Level |
|---|---|---|
| Row Index vs Semantic | Phase 1 (Data Model) | CRITICAL |
| Travel as Structural | Phase 1 (Data Model) | CRITICAL |
| Hard-coded Flatten | Phase 2 (View) | CRITICAL |
| Data Out of Sync | Phase 2 (View) | HIGH |
| reloadData vs Batch | Phase 2 (View) | HIGH |
| Coordinate Space | Phase 2 (Constraints) | HIGH |
| iPhone Drag Disabled | Phase 2 (Setup) | MEDIUM |
| NSItemProvider Catalyst | Phase 2 (if Mac) | MEDIUM |
| Nil Destination | Phase 2 (Drop) | MEDIUM |
| sortOrder Precision | Phase 3 (Maintenance) | LOW |
| Section Headers | Phase 2 (Validation) | MEDIUM |
| SwiftUI Update Loop | Phase 2 (Bridging) | MEDIUM |
Sources
- Apple: Adopting drag and drop in a table view
- Apple: Supporting drag and drop in table views
- WWDC 2017 Session 223: Drag and Drop with Collection and Table View
- Apple Developer Forums: UITableView Drag Drop between sections
- Apple Developer Forums: Drag and drop reorder not working on iPhone
- Swiftjective-C: Drag to Reorder with Diffable Datasource
- Bumble Tech: Batch updates for UITableView and UICollectionView
- Hacking with Swift: How to add drag and drop to your app
- SportsTime codebase analysis:
ItineraryTableViewController.swift,ItineraryConstraints.swift,CONCERNS.md