Files
SportstimeAPI/.planning/research/PITFALLS.md
Trey t 6f09c0abcb docs: complete project research for itinerary editor
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>
2026-01-18 13:16:07 -06:00

381 lines
16 KiB
Markdown

# 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:**
1. Define canonical semantic model: `(day: Int, sortOrder: Double)` per item
2. Row indices are DISPLAY concerns only - never persist them
3. All constraint validation operates on semantic positions, not rows
4. After drop, immediately calculate semantic position, discard row index
**Detection (Warning Signs):**
- Code stores row indices anywhere except during drag
- Constraint checks reference `indexPath.row` instead of `item.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:**
1. Travel is an item with `kind: .travel`, not a day property
2. Use `sortOrder < 0` convention for "before games", `sortOrder >= 0` for "after games"
3. Travel follows same drag/drop code path as custom items (with additional constraints)
4. Store travel position the same way as other items: `(day, sortOrder)`
**Detection (Warning Signs):**
- Data model has `travelBefore` or `travelDay` as 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:**
1. Flatten algorithm MUST sort by `sortOrder` within each day
2. Use `sortOrder < 0` convention to place items before games, `sortOrder >= 0` after
3. Write test: "items render in sortOrder order after reload"
4. Single source of truth: `flatItems = items.sorted(by: { $0.sortOrder < $1.sortOrder })`
**Detection (Warning Signs):**
- Flatten code has `if .travel { append }` followed by `if .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:**
1. Never call `reloadData()` during active drag
2. Update data model in `moveRowAt:to:` completion (after UITableView has settled)
3. Guard SwiftUI updates that would trigger re-render during drag
4. Use `draggingItem != nil` flag to skip external data updates
**Detection (Warning Signs):**
- Crashes during drag (not on drop)
- SwiftUI parent triggers updates that propagate to UIKit during drag
- `performBatchUpdates` called 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:**
1. After drop, update `flatItems` in place (remove/insert)
2. Let UITableView's internal move animation complete naturally
3. Only call `reloadData()` for external data changes (not user reorder)
4. 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 `reloadData` called in `moveRowAt: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:**
1. Understand UIKit's drag semantics: destination is in "post-removal" space
2. When validating constraints, simulate the move first
3. Pre-compute valid destination rows in proposed coordinate space at drag start
4. 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:**
```swift
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:**
```swift
// 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:**
```swift
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:**
1. Double has ~15 significant digits - sufficient for ~50 midpoint insertions
2. For extreme cases, implement "normalize" function that resets to 1.0, 2.0, 3.0...
3. 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:**
1. Day headers are non-reorderable (`canMoveRowAt` returns false)
2. `targetIndexPathForMoveFromRowAt` redirects drops ON headers to AFTER headers
3. 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:**
1. Track header height changes with threshold (`abs(new - old) > 1.0`)
2. Use `isAdjustingHeader` guard to prevent re-entrant updates
3. 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:**
1. `draggingItem != nil` flag guards against external updates
2. Never call `reloadData()` in `moveRowAt:to:`
3. 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
- [ ] `travelDay` or `travelBefore` as a day property
- [ ] No `sortOrder` field 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](https://developer.apple.com/documentation/uikit/drag_and_drop/adopting_drag_and_drop_in_a_table_view)
- [Apple: Supporting drag and drop in table views](https://developer.apple.com/documentation/uikit/views_and_controls/table_views/supporting_drag_and_drop_in_table_views)
- [WWDC 2017 Session 223: Drag and Drop with Collection and Table View](https://asciiwwdc.com/2017/sessions/223)
- [Apple Developer Forums: UITableView Drag Drop between sections](https://developer.apple.com/forums/thread/96034)
- [Apple Developer Forums: Drag and drop reorder not working on iPhone](https://developer.apple.com/forums/thread/80873)
- [Swiftjective-C: Drag to Reorder with Diffable Datasource](https://swiftjectivec.com/Tableview-Diffable-Datasource-Drag-to-Reorder/)
- [Bumble Tech: Batch updates for UITableView and UICollectionView](https://medium.com/bumble-tech/batch-updates-for-uitableview-and-uicollectionview-baaa1e6a66b5)
- [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)
- SportsTime codebase analysis: `ItineraryTableViewController.swift`, `ItineraryConstraints.swift`, `CONCERNS.md`