# 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`