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>
381 lines
16 KiB
Markdown
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`
|