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>
This commit is contained in:
423
.planning/research/ARCHITECTURE.md
Normal file
423
.planning/research/ARCHITECTURE.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# Architecture Research: Semantic Drag-Drop
|
||||
|
||||
**Project:** SportsTime Itinerary Editor
|
||||
**Researched:** 2026-01-18
|
||||
**Confidence:** HIGH (based on existing implementation analysis)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The SportsTime codebase already contains a well-architected semantic drag-drop system. This document captures the existing architecture, identifies the key design decisions that make it work, and recommends refinements for maintainability.
|
||||
|
||||
The core insight: **UITableView operates on row indices, but the semantic model uses (day: Int, sortOrder: Double)**. The architecture must cleanly separate these coordinate systems while maintaining bidirectional mapping.
|
||||
|
||||
---
|
||||
|
||||
## Component Layers
|
||||
|
||||
### Layer 1: Semantic Position Model
|
||||
|
||||
**Responsibility:** Own the source of truth for item positions using semantic coordinates.
|
||||
|
||||
**Location:** `ItineraryItem.swift`
|
||||
|
||||
```
|
||||
ItineraryItem {
|
||||
day: Int // 1-indexed day number
|
||||
sortOrder: Double // Position within day (fractional for unlimited insertion)
|
||||
kind: ItemKind // .game, .travel, .custom
|
||||
}
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
|
||||
1. **Day-based positioning** - Items belong to days, not absolute positions
|
||||
2. **Fractional sortOrder** - Enables midpoint insertion without renumbering
|
||||
3. **sortOrder convention** - `< 0` = before games, `>= 0` = after games
|
||||
|
||||
**Why this works:** The semantic model is independent of visual representation. Moving an item means updating `(day, sortOrder)`, not recalculating row indices.
|
||||
|
||||
---
|
||||
|
||||
### Layer 2: Constraint Validation
|
||||
|
||||
**Responsibility:** Determine valid positions for each item type.
|
||||
|
||||
**Location:** `ItineraryConstraints.swift`
|
||||
|
||||
```
|
||||
ItineraryConstraints {
|
||||
isValidPosition(for item, day, sortOrder) -> Bool
|
||||
validDayRange(for item) -> ClosedRange<Int>?
|
||||
barrierGames(for item) -> [ItineraryItem]
|
||||
}
|
||||
```
|
||||
|
||||
**Constraint Rules:**
|
||||
|
||||
| Item Type | Day Constraint | sortOrder Constraint |
|
||||
|-----------|---------------|---------------------|
|
||||
| Game | Fixed (immovable) | Fixed |
|
||||
| Travel | After last from-city game, before first to-city game | After from-city games on same day, before to-city games on same day |
|
||||
| Custom | Any day (1...tripDayCount) | Any position |
|
||||
|
||||
**Why this layer exists:** Drag operations need real-time validation. Having a dedicated constraint engine enables:
|
||||
- Pre-computing valid drop zones at drag start
|
||||
- Haptic feedback when entering/exiting valid zones
|
||||
- Visual dimming of invalid targets
|
||||
|
||||
---
|
||||
|
||||
### Layer 3: Visual Flattening
|
||||
|
||||
**Responsibility:** Transform semantic model into flat row array for UITableView.
|
||||
|
||||
**Location:** `ItineraryTableViewWrapper.swift` (buildItineraryData) + `ItineraryTableViewController.swift` (reloadData)
|
||||
|
||||
**Data Transformation:**
|
||||
|
||||
```
|
||||
[ItineraryItem] (semantic)
|
||||
|
|
||||
v
|
||||
[ItineraryDayData] (grouped by day, with travel/custom items)
|
||||
|
|
||||
v
|
||||
[ItineraryRowItem] (flat row array for UITableView)
|
||||
```
|
||||
|
||||
**Row Ordering (per day):**
|
||||
|
||||
1. Day header + Add button (merged into one row)
|
||||
2. Items with sortOrder < 0 (before games)
|
||||
3. Games row (all games bundled)
|
||||
4. Items with sortOrder >= 0 (after games)
|
||||
|
||||
**Why flattening is separate:** The UITableView needs contiguous row indices. By keeping flattening in its own layer:
|
||||
- Semantic model stays clean
|
||||
- Row calculation is centralized
|
||||
- Changes to visual layout don't affect data model
|
||||
|
||||
---
|
||||
|
||||
### Layer 4: Drop Slot Calculation
|
||||
|
||||
**Responsibility:** Translate row indices back to semantic positions during drag.
|
||||
|
||||
**Location:** `ItineraryTableViewController.swift` (calculateSortOrder, dayNumber)
|
||||
|
||||
**Key Functions:**
|
||||
|
||||
```swift
|
||||
// Row -> Semantic Day
|
||||
dayNumber(forRow:) -> Int
|
||||
// Scans backward to find dayHeader
|
||||
|
||||
// Row -> Semantic sortOrder
|
||||
calculateSortOrder(at row:) -> Double
|
||||
// Uses midpoint insertion algorithm
|
||||
```
|
||||
|
||||
**Midpoint Insertion Algorithm:**
|
||||
|
||||
```
|
||||
Existing: A (sortOrder: 1.0), B (sortOrder: 2.0)
|
||||
Drop between A and B:
|
||||
newSortOrder = (1.0 + 2.0) / 2 = 1.5
|
||||
|
||||
Edge cases:
|
||||
- First in day: existing_min / 2
|
||||
- Last in day: existing_max + 1.0
|
||||
- Empty day: 1.0
|
||||
```
|
||||
|
||||
**Why this is complex:** UITableView's `moveRowAt:to:` gives us destination row indices, but we need to fire callbacks with semantic `(day, sortOrder)`. This layer bridges the gap.
|
||||
|
||||
---
|
||||
|
||||
### Layer 5: Drag Interaction
|
||||
|
||||
**Responsibility:** Handle UITableView drag-and-drop with constraints.
|
||||
|
||||
**Location:** `ItineraryTableViewController.swift` (targetIndexPathForMoveFromRowAt)
|
||||
|
||||
**Key Behaviors:**
|
||||
|
||||
1. **Drag Start:** Compute valid destination rows (proposed coordinate space)
|
||||
2. **During Drag:** Snap to nearest valid position if proposed is invalid
|
||||
3. **Drag End:** Calculate semantic position, fire callback
|
||||
|
||||
**Coordinate System Challenge:**
|
||||
|
||||
UITableView's `targetIndexPathForMoveFromRowAt:toProposedIndexPath:` uses "proposed" coordinates (array with source row removed). This requires:
|
||||
|
||||
```swift
|
||||
// At drag start, precompute valid destinations in proposed space
|
||||
validDestinationRowsProposed = computeValidDestinationRowsProposed(...)
|
||||
|
||||
// During drag, snap to nearest valid
|
||||
if !validDestinationRowsProposed.contains(proposedRow) {
|
||||
return nearestValue(in: validDestinationRowsProposed, to: proposedRow)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ TripDetailView (SwiftUI) │
|
||||
│ │
|
||||
│ State: │
|
||||
│ - trip: Trip │
|
||||
│ - itineraryItems: [ItineraryItem] <- Source of truth │
|
||||
│ - travelOverrides: [String: TravelOverride] │
|
||||
│ │
|
||||
│ Callbacks: │
|
||||
│ - onTravelMoved(travelId, newDay, newSortOrder) │
|
||||
│ - onCustomItemMoved(itemId, newDay, newSortOrder) │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ItineraryTableViewWrapper (UIViewControllerRepresentable)│
|
||||
│ │
|
||||
│ Transform: │
|
||||
│ - buildItineraryData() -> ([ItineraryDayData], validRanges) │
|
||||
│ - Passes callbacks to controller │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ItineraryTableViewController (UIKit) │
|
||||
│ │
|
||||
│ Flattening: │
|
||||
│ - reloadData(days) -> flatItems: [ItineraryRowItem] │
|
||||
│ │
|
||||
│ Drag Logic: │
|
||||
│ - targetIndexPathForMoveFromRowAt (constraint validation) │
|
||||
│ - moveRowAt:to: (fire callback with semantic position) │
|
||||
│ │
|
||||
│ Drop Slot Calculation: │
|
||||
│ - dayNumber(forRow:) + calculateSortOrder(at:) │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Callbacks to Parent │
|
||||
│ │
|
||||
│ onCustomItemMoved(itemId, day: 3, sortOrder: 1.5) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ TripDetailView updates itineraryItems -> SwiftUI re-renders │
|
||||
│ ItineraryItemService syncs to CloudKit │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Interfaces
|
||||
|
||||
### Semantic Model Interface
|
||||
|
||||
```swift
|
||||
protocol SemanticPosition {
|
||||
var day: Int { get }
|
||||
var sortOrder: Double { get }
|
||||
}
|
||||
|
||||
protocol PositionConstraint {
|
||||
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool
|
||||
func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>?
|
||||
}
|
||||
```
|
||||
|
||||
### Flattening Interface
|
||||
|
||||
```swift
|
||||
protocol ItineraryFlattener {
|
||||
func flatten(days: [ItineraryDayData]) -> [ItineraryRowItem]
|
||||
func dayNumber(forRow row: Int, in items: [ItineraryRowItem]) -> Int
|
||||
func calculateSortOrder(at row: Int, in items: [ItineraryRowItem]) -> Double
|
||||
}
|
||||
```
|
||||
|
||||
### Drag Interaction Interface
|
||||
|
||||
```swift
|
||||
protocol DragConstraintValidator {
|
||||
func computeValidDestinationRows(
|
||||
sourceRow: Int,
|
||||
item: ItineraryRowItem,
|
||||
constraints: ItineraryConstraints
|
||||
) -> [Int]
|
||||
|
||||
func nearestValidRow(to proposed: Int, in validRows: [Int]) -> Int
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Order (Dependencies)
|
||||
|
||||
Phase structure based on what depends on what:
|
||||
|
||||
### Phase 1: Semantic Position Model
|
||||
|
||||
**Build:**
|
||||
- `ItineraryItem` struct with `day`, `sortOrder`, `kind`
|
||||
- Unit tests for fractional sortOrder behavior
|
||||
|
||||
**No dependencies.** This is the foundation.
|
||||
|
||||
### Phase 2: Constraint Validation
|
||||
|
||||
**Build:**
|
||||
- `ItineraryConstraints` with validation rules
|
||||
- Unit tests for travel constraint edge cases
|
||||
|
||||
**Depends on:** Phase 1 (ItineraryItem)
|
||||
|
||||
### Phase 3: Visual Flattening
|
||||
|
||||
**Build:**
|
||||
- `ItineraryRowItem` enum (row types)
|
||||
- `ItineraryDayData` structure
|
||||
- Flattening algorithm
|
||||
- Unit tests for row ordering
|
||||
|
||||
**Depends on:** Phase 1 (ItineraryItem)
|
||||
|
||||
### Phase 4: Drop Slot Calculation
|
||||
|
||||
**Build:**
|
||||
- `dayNumber(forRow:)` implementation
|
||||
- `calculateSortOrder(at:)` with midpoint insertion
|
||||
- Unit tests for sortOrder calculation
|
||||
|
||||
**Depends on:** Phase 3 (flattened row array)
|
||||
|
||||
### Phase 5: Drag Interaction
|
||||
|
||||
**Build:**
|
||||
- `targetIndexPathForMoveFromRowAt` with constraint snapping
|
||||
- Drag state management (visual feedback, haptics)
|
||||
- Integration with UITableView
|
||||
|
||||
**Depends on:** Phase 2 (constraints), Phase 4 (drop slot calculation)
|
||||
|
||||
### Phase 6: Integration
|
||||
|
||||
**Build:**
|
||||
- `ItineraryTableViewWrapper` bridge
|
||||
- SwiftUI parent view with state and callbacks
|
||||
- CloudKit persistence
|
||||
|
||||
**Depends on:** All previous phases
|
||||
|
||||
---
|
||||
|
||||
## The Reload Problem
|
||||
|
||||
**Problem Statement:**
|
||||
|
||||
> Data reloads frequently from SwiftUI/SwiftData. Previous attempts failed because row logic and semantic logic were tangled.
|
||||
|
||||
**How This Architecture Solves It:**
|
||||
|
||||
1. **Semantic state is authoritative.** SwiftUI's `itineraryItems: [ItineraryItem]` is the source of truth. Reloads always regenerate the flat row array from semantic state.
|
||||
|
||||
2. **Flattening is deterministic.** Given the same `[ItineraryItem]`, flattening produces the same `[ItineraryRowItem]`. No state is stored in the row array.
|
||||
|
||||
3. **Drag callbacks return semantic positions.** When drag completes, `onCustomItemMoved(id, day, sortOrder)` returns semantic coordinates. The parent updates `itineraryItems`, which triggers a reload.
|
||||
|
||||
4. **UITableView.reloadData() is safe.** Because semantic state survives, calling `reloadData()` after any external update just re-flattens. Scroll position may need preservation, but data integrity is maintained.
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```
|
||||
External Update -> itineraryItems changes
|
||||
-> ItineraryTableViewWrapper.updateUIViewController
|
||||
-> buildItineraryData() re-flattens
|
||||
-> controller.reloadData()
|
||||
-> UITableView renders new state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Anti-Pattern 1: Storing Semantic State in Row Indices
|
||||
|
||||
**Bad:**
|
||||
```swift
|
||||
var itemPositions: [UUID: Int] // Item ID -> row index
|
||||
```
|
||||
|
||||
**Why bad:** Row indices change when items are added/removed/reordered. This creates sync issues.
|
||||
|
||||
**Good:** Store `(day: Int, sortOrder: Double)` which is independent of row count.
|
||||
|
||||
### Anti-Pattern 2: Calculating sortOrder from Row Index at Rest
|
||||
|
||||
**Bad:**
|
||||
```swift
|
||||
// In the semantic model
|
||||
item.sortOrder = Double(rowIndex) // Re-assign on every reload
|
||||
```
|
||||
|
||||
**Why bad:** Causes sortOrder drift. After multiple reloads, sortOrders become meaningless.
|
||||
|
||||
**Good:** Only calculate sortOrder at drop time using midpoint insertion.
|
||||
|
||||
### Anti-Pattern 3: Mixing Coordinate Systems in Constraint Validation
|
||||
|
||||
**Bad:**
|
||||
```swift
|
||||
func isValidDropTarget(proposedRow: Int) -> Bool {
|
||||
// Directly checks row index against day header positions
|
||||
return proposedRow > dayHeaderRow
|
||||
}
|
||||
```
|
||||
|
||||
**Why bad:** Mixes proposed coordinate space with current array indices.
|
||||
|
||||
**Good:** Convert proposed row to semantic `(day, sortOrder)` first, then validate semantically.
|
||||
|
||||
---
|
||||
|
||||
## Scalability Considerations
|
||||
|
||||
| Concern | Current (10-20 rows) | At 100 rows | At 500+ rows |
|
||||
|---------|---------------------|-------------|--------------|
|
||||
| Flattening | Instant | Fast (<10ms) | Consider caching |
|
||||
| Constraint validation | Per-drag | Per-drag | Pre-compute at load |
|
||||
| UITableView performance | Native | Native | Cell recycling critical |
|
||||
| sortOrder precision | Perfect | Perfect | Consider normalization after 1000s of edits |
|
||||
|
||||
---
|
||||
|
||||
## Existing Implementation Quality
|
||||
|
||||
The SportsTime codebase already implements this architecture well. Key observations:
|
||||
|
||||
**Strengths:**
|
||||
- Clear separation between semantic model and row array
|
||||
- ItineraryConstraints is a dedicated validation layer
|
||||
- Midpoint insertion is correctly implemented
|
||||
- Coordinate system translation (proposed vs current) is handled
|
||||
|
||||
**Areas for Refinement:**
|
||||
- Consider extracting flattening into a dedicated `ItineraryFlattener` type
|
||||
- Unit tests for edge cases in sortOrder calculation
|
||||
- Documentation of the "proposed coordinate space" behavior
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Existing codebase analysis: `ItineraryTableViewController.swift`, `ItineraryTableViewWrapper.swift`, `ItineraryItem.swift`, `ItineraryConstraints.swift`
|
||||
- [SwiftUI + UIKit Hybrid Architecture Guide](https://ravi6997.medium.com/swiftui-uikit-hybrid-app-architecture-b17d8be139d8)
|
||||
- [Modern iOS Frontend Architecture (2025)](https://medium.com/@bhumibhuva18/modern-ios-frontend-architecture-swiftui-uikit-and-the-patterns-that-scale-in-2025-c7ba5c35f55e)
|
||||
- [SwiftReorder Library](https://github.com/adamshin/SwiftReorder) - Reference implementation
|
||||
- [Drag and Drop UX Design Best Practices](https://www.pencilandpaper.io/articles/ux-pattern-drag-and-drop)
|
||||
243
.planning/research/FEATURES.md
Normal file
243
.planning/research/FEATURES.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Features Research: Drag-Drop Editor UX
|
||||
|
||||
**Domain:** Drag-and-drop itinerary editor for iOS sports travel app
|
||||
**Researched:** 2026-01-18
|
||||
**Confidence:** HIGH (multiple authoritative sources cross-verified)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Polished drag-drop requires deliberate visual feedback at every state transition. The difference between "feels broken" and "feels delightful" comes down to: lift animation, predictable insertion indicators, smooth reshuffling, and magnetic snap-to-place. Your constraints (fixed day headers, fixed games, movable travel/custom items) add complexity but are achievable with proper drop zone logic.
|
||||
|
||||
---
|
||||
|
||||
## Table Stakes
|
||||
|
||||
Features users expect. Missing any of these makes the editor feel broken.
|
||||
|
||||
| Feature | Why Expected | Complexity | Implementation Notes |
|
||||
|---------|--------------|------------|---------------------|
|
||||
| **Lift animation on grab** | Users expect physical metaphor - picking up an object | Low | Elevation (shadow), scale 1.02-1.05x, slight z-offset |
|
||||
| **Ghost/placeholder at origin** | Shows where item came from, reduces anxiety | Low | Semi-transparent copy or outlined placeholder in original position |
|
||||
| **Insertion indicator line** | Must show exactly where item will drop | Medium | Horizontal line with small terminal bleeds, appears between items |
|
||||
| **Items move out of the way** | Preview of final state while dragging | Medium | ~100ms animation, triggered when dragged item center overlaps edge |
|
||||
| **Magnetic snap on drop** | Satisfying completion, confirms action worked | Low | 100ms ease-out animation to final position |
|
||||
| **Clear invalid drop feedback** | Don't leave user guessing why drop failed | Low | Item animates back to origin if dropped in invalid zone |
|
||||
| **Touch hold delay (300-500ms)** | Distinguish tap from drag intent | Low | iOS standard; prevents accidental drags |
|
||||
| **Haptic on grab** | Tactile confirmation drag started | Low | UIImpactFeedbackGenerator.light on pickup |
|
||||
| **Haptic on drop** | Tactile confirmation action completed | Low | UIImpactFeedbackGenerator.medium on successful drop |
|
||||
| **Scroll when dragging to edge** | Lists longer than viewport need auto-scroll | Medium | Scroll speed increases closer to edge, ~40px threshold |
|
||||
|
||||
### Insertion Indicator Details
|
||||
|
||||
The insertion line is critical. Best practices:
|
||||
- Appears **between** items (in the gap), not on top
|
||||
- Has small terminal bleeds (~4px) extending past item edges
|
||||
- Triggered when center of dragged item crosses edge of potential neighbor
|
||||
- Color should contrast clearly (system accent or distinct color)
|
||||
|
||||
### Animation Timing
|
||||
|
||||
| Event | Duration | Easing |
|
||||
|-------|----------|--------|
|
||||
| Lift (pickup) | 150ms | ease-out |
|
||||
| Items shuffling | 100ms | ease-out |
|
||||
| Snap to place (drop) | 100ms | ease-out |
|
||||
| Return to origin (cancel) | 200ms | ease-in-out |
|
||||
|
||||
---
|
||||
|
||||
## Nice-to-Have
|
||||
|
||||
Polish features that delight but aren't expected.
|
||||
|
||||
| Feature | Value | Complexity | Notes |
|
||||
|---------|-------|------------|-------|
|
||||
| **Slight tilt on drag (2-3 degrees)** | Trello's signature polish; makes interaction feel playful | Low | Rotate3D effect, matches brand personality |
|
||||
| **Progressive drop zone highlighting** | Visual intensifies as item approaches valid zone | Medium | Background color change, border enhancement |
|
||||
| **Multi-item drag with count badge** | Power users moving multiple items at once | High | Not needed for v1; itinerary items are usually moved one at a time |
|
||||
| **Keyboard reordering (a11y)** | Up/Down arrows via rotor actions | Medium | Important for accessibility; add accessibilityActions |
|
||||
| **Undo after drop** | Recover from mistakes | Medium | Toast with "Undo" button, ~5 second timeout |
|
||||
| **Drag handle icon** | Visual affordance for draggability | Low | 6-dot grip icon (Notion-style) or horizontal lines |
|
||||
| **Cancel drag with escape/shake** | Quick abort | Low | Shake-to-cancel on iOS; return to origin |
|
||||
| **Drop zone "ready" state** | Zone visually activates before item enters | Low | Subtle background shift when drag starts |
|
||||
|
||||
### Tilt Animation (Trello-style)
|
||||
|
||||
The 2-3 degree tilt on dragged items is considered "gold standard" polish:
|
||||
- Adds personality without being distracting
|
||||
- Reinforces physical metaphor (picking up a card)
|
||||
- Should match your app's design language (may be too playful for some apps)
|
||||
|
||||
---
|
||||
|
||||
## Overkill
|
||||
|
||||
Skip these - high complexity, low value for an itinerary editor.
|
||||
|
||||
| Feature | Why Skip | What to Do Instead |
|
||||
|---------|----------|-------------------|
|
||||
| **Drag between sections/screens** | Your items live within days; cross-day moves are rare | Allow within same list only, or use "Move to..." action menu |
|
||||
| **Nested drag-drop** | Games within days is hierarchy enough | Keep flat list per day section |
|
||||
| **Free-form canvas positioning** | Not applicable to linear itinerary | Stick to list reordering |
|
||||
| **Real-time collaborative drag** | Massive sync complexity | Single-user editing |
|
||||
| **Drag-to-resize** | Items don't have variable size | Fixed item heights |
|
||||
| **Custom drag preview images** | Native preview is sufficient | Use default lifted appearance |
|
||||
| **Physics-based spring animations** | Overkill for list reordering | Simple ease-out is fine |
|
||||
|
||||
---
|
||||
|
||||
## Interactions to Support
|
||||
|
||||
Specific drag scenarios for your itinerary context.
|
||||
|
||||
### Scenario 1: Move Custom Item Within Same Day
|
||||
|
||||
**User intent:** Reorder "Dinner at Lou Malnati's" from after to before the Cubs game
|
||||
|
||||
**Expected behavior:**
|
||||
1. Long-press on custom item (300ms) - haptic feedback
|
||||
2. Item lifts (shadow + scale), ghost remains at origin
|
||||
3. Drag within day section - insertion line appears between valid positions
|
||||
4. Games and travel segments shuffle with 100ms animation
|
||||
5. Drop - item snaps into place, haptic confirms
|
||||
|
||||
**Constraints:**
|
||||
- Custom item can move anywhere within the day
|
||||
- Cannot move before/after day header
|
||||
- Cannot replace or overlay a game (games are fixed)
|
||||
|
||||
### Scenario 2: Move Custom Item to Different Day
|
||||
|
||||
**User intent:** Move hotel check-in from Day 2 to Day 1
|
||||
|
||||
**Expected behavior:**
|
||||
1. Long-press and lift
|
||||
2. Drag toward Day 1 section
|
||||
3. Auto-scroll if Day 1 is off-screen
|
||||
4. Insertion line appears at valid positions in Day 1
|
||||
5. Day 2 collapses to show item removed; Day 1 expands
|
||||
6. Drop - item now in Day 1
|
||||
|
||||
**Constraints:**
|
||||
- Can cross day boundaries
|
||||
- Still cannot land on games
|
||||
|
||||
### Scenario 3: Move Travel Segment (Constrained)
|
||||
|
||||
**User intent:** Move "Drive: Chicago to Milwaukee" earlier in the day
|
||||
|
||||
**Expected behavior:**
|
||||
1. Long-press on travel segment
|
||||
2. Item lifts (possibly with different visual treatment since it's constrained)
|
||||
3. Insertion line only appears at **valid** positions (before/after games it connects)
|
||||
4. Invalid positions show no insertion line (or dimmed indicator)
|
||||
5. If dropped at invalid position, item animates back to origin
|
||||
|
||||
**Constraints:**
|
||||
- Travel segments connect stadiums/locations
|
||||
- Can only move within logical route order
|
||||
- Must validate position before showing insertion indicator
|
||||
|
||||
### Scenario 4: Attempt to Move Fixed Item (Game)
|
||||
|
||||
**User intent:** User tries to drag a game (not allowed)
|
||||
|
||||
**Expected behavior:**
|
||||
1. Long-press on game item
|
||||
2. **No lift animation** - item doesn't respond as draggable
|
||||
3. Optionally: subtle shake or tooltip "Games cannot be reordered"
|
||||
4. User understands this item is fixed
|
||||
|
||||
**Visual differentiation:**
|
||||
- Fixed items should NOT have drag handles
|
||||
- Could have different visual treatment (no grip icon, different background)
|
||||
|
||||
### Scenario 5: Drag to Invalid Zone
|
||||
|
||||
**User intent:** User drags custom item but releases over a game
|
||||
|
||||
**Expected behavior:**
|
||||
1. Item is being dragged
|
||||
2. Hovers over game - no insertion line appears (invalid)
|
||||
3. User releases
|
||||
4. Item animates back to origin (~200ms)
|
||||
5. Optional: brief error state or haptic warning
|
||||
|
||||
---
|
||||
|
||||
## Visual States Summary
|
||||
|
||||
| Element State | Visual Treatment |
|
||||
|--------------|------------------|
|
||||
| **Resting (draggable)** | Normal appearance, optional drag handle icon on hover/focus |
|
||||
| **Resting (fixed)** | Normal, but NO drag handle; visually distinct |
|
||||
| **Lifted/grabbed** | Elevated (shadow), slight scale up (1.02-1.05), optional tilt |
|
||||
| **Ghost at origin** | Semi-transparent (30-50% opacity) or outlined placeholder |
|
||||
| **Insertion line** | Accent-colored horizontal line, ~2px height, bleeds past edges |
|
||||
| **Invalid drop zone** | No insertion line; item over zone dims or shows warning |
|
||||
| **Drop zone ready** | Subtle background color shift when any drag starts |
|
||||
| **Dropped/success** | Snaps to place, haptic feedback, ghost disappears |
|
||||
| **Cancelled/error** | Returns to origin with animation, optional warning haptic |
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Requirements
|
||||
|
||||
| Requirement | Implementation | Priority |
|
||||
|-------------|----------------|----------|
|
||||
| **VoiceOver reordering** | accessibilityActions with "Move Up" / "Move Down" | High |
|
||||
| **Rotor integration** | Actions appear in VoiceOver rotor | High |
|
||||
| **Focus management** | Focus follows moved item after reorder | Medium |
|
||||
| **Live region announcements** | Announce position change ("Item moved to position 3") | Medium |
|
||||
| **Fallback buttons** | Optional up/down arrows as visual alternative | Low (nice to have) |
|
||||
|
||||
SwiftUI example for accessibility:
|
||||
```swift
|
||||
.accessibilityAction(named: "Move Up") { moveItemUp(item) }
|
||||
.accessibilityAction(named: "Move Down") { moveItemDown(item) }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile-Specific Considerations
|
||||
|
||||
| Concern | Solution |
|
||||
|---------|----------|
|
||||
| **Fat finger problem** | Minimum 44x44pt touch targets; drag handles at least 44pt wide |
|
||||
| **Scroll vs. drag conflict** | Long-press delay (300-500ms) distinguishes intent |
|
||||
| **Viewport limitations** | Auto-scroll at edges (40px threshold), speed increases near edge |
|
||||
| **One-handed use** | Consider "Move to..." button as alternative to long-distance drags |
|
||||
| **Accidental drops** | Generous drop zones; magnetic snap; undo option |
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Why Bad | Do Instead |
|
||||
|--------------|---------|------------|
|
||||
| **Edge-to-edge shuffle trigger** | Feels "twitchy", items move unexpectedly | Use center-overlap-edge trigger |
|
||||
| **Instant reshuffle (no animation)** | Disorienting, hard to track what moved | 100ms animated transitions |
|
||||
| **No ghost/placeholder** | User loses context of original position | Always show origin indicator |
|
||||
| **Drag handle too small** | Frustrating on touch | Minimum 44pt, ideally larger |
|
||||
| **Remove item during drag** | Anxiety - "where did it go?" | Keep ghost visible at origin |
|
||||
| **Scroll too fast at edges** | Overshoots, loses control | Gradual speed increase |
|
||||
| **No invalid feedback** | User thinks interaction is broken | Clear visual/haptic for invalid drops |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
**High Confidence (verified with multiple authoritative sources):**
|
||||
- [Smart Interface Design Patterns - Drag and Drop UX](https://smart-interface-design-patterns.com/articles/drag-and-drop-ux/)
|
||||
- [Atlassian Pragmatic Drag and Drop Design Guidelines](https://atlassian.design/components/pragmatic-drag-and-drop/design-guidelines/)
|
||||
- [Pencil & Paper - Drag & Drop UX Design Best Practices](https://www.pencilandpaper.io/articles/ux-pattern-drag-and-drop)
|
||||
- [Nielsen Norman Group - Drag and Drop: How to Design for Ease of Use](https://www.nngroup.com/articles/drag-drop/)
|
||||
|
||||
**Medium Confidence (single authoritative source):**
|
||||
- [LogRocket - Designing Drag and Drop UIs](https://blog.logrocket.com/ux-design/drag-and-drop-ui-examples/)
|
||||
- [Darin Senneff - Designing a Reorderable List Component](https://www.darins.page/articles/designing-a-reorderable-list-component)
|
||||
- [Apple Human Interface Guidelines - Drag and Drop](https://developer.apple.com/design/human-interface-guidelines/drag-and-drop)
|
||||
|
||||
**Low Confidence (community patterns):**
|
||||
- Various SwiftUI implementation guides (verify APIs against current documentation)
|
||||
- Trello UX patterns referenced in multiple articles (de facto standard)
|
||||
380
.planning/research/PITFALLS.md
Normal file
380
.planning/research/PITFALLS.md
Normal file
@@ -0,0 +1,380 @@
|
||||
# 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`
|
||||
291
.planning/research/STACK.md
Normal file
291
.planning/research/STACK.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Stack Research: UITableView Drag-Drop for Itinerary Editor
|
||||
|
||||
**Project:** SportsTime Itinerary Editor
|
||||
**Researched:** 2026-01-18
|
||||
**Overall Confidence:** HIGH (existing implementation in codebase + stable APIs)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The SportsTime codebase already contains a production-quality UITableView drag-drop implementation in `ItineraryTableViewController.swift` and `ItineraryTableViewWrapper.swift`. This research validates that approach and documents the recommended stack for extending it to support external drops.
|
||||
|
||||
**Key Finding:** The existing implementation uses the traditional `canMoveRowAt`/`moveRowAt` approach with `tableView.isEditing = true`. For external drops (from outside the table), the codebase will need to add `UITableViewDropDelegate` protocol conformance.
|
||||
|
||||
---
|
||||
|
||||
## Recommended APIs
|
||||
|
||||
### Core APIs (Already in Use)
|
||||
|
||||
| API | Purpose | Confidence |
|
||||
|-----|---------|------------|
|
||||
| `UITableViewController` | Native table with built-in drag handling | HIGH |
|
||||
| `tableView.isEditing = true` | Enables drag handles on rows | HIGH |
|
||||
| `canMoveRowAt:` | Controls which rows show drag handles | HIGH |
|
||||
| `moveRowAt:to:` | Called when reorder completes | HIGH |
|
||||
| `targetIndexPathForMoveFromRowAt:toProposedIndexPath:` | Real-time validation during drag | HIGH |
|
||||
| `UIHostingConfiguration` | Embeds SwiftUI views in cells | HIGH |
|
||||
|
||||
**Rationale:** These APIs provide the smooth, native iOS reordering experience with real-time insertion line feedback. The existing implementation demonstrates this working well.
|
||||
|
||||
### APIs Needed for External Drops
|
||||
|
||||
| API | Purpose | When to Use | Confidence |
|
||||
|-----|---------|-------------|------------|
|
||||
| `UITableViewDropDelegate` | Accept drops from outside the table | Required for external drops | HIGH |
|
||||
| `UITableViewDragDelegate` | Provide drag items (not strictly needed if only receiving) | Optional | HIGH |
|
||||
| `dropSessionDidUpdate(_:withDestinationIndexPath:)` | Validate drop during hover | Shows insertion feedback for external drags | HIGH |
|
||||
| `performDropWith(_:)` | Handle external drop completion | Called only for external drops (not internal moves) | HIGH |
|
||||
| `canHandle(_:)` | Validate drop session types | Filter what can be dropped | HIGH |
|
||||
| `NSItemProvider` | Data transfer wrapper | Encodes dragged item data | HIGH |
|
||||
| `UIDragItem.localObject` | In-app optimization | Avoids encoding when drag is same-app | HIGH |
|
||||
|
||||
**Rationale:** For external drops, `UITableViewDropDelegate` is required. The key insight from research: when both `moveRowAt:` and `performDropWith:` are implemented, UIKit automatically routes internal reorders through `moveRowAt:` and external drops through `performDropWith:`. This is documented behavior.
|
||||
|
||||
---
|
||||
|
||||
## SwiftUI Integration Pattern
|
||||
|
||||
### Current Pattern (Validated)
|
||||
|
||||
The codebase uses `UIViewControllerRepresentable` with a Coordinator pattern:
|
||||
|
||||
```swift
|
||||
struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresentable {
|
||||
// Callbacks for data mutations (lifted state)
|
||||
var onTravelMoved: ((String, Int, Double) -> Void)?
|
||||
var onCustomItemMoved: ((UUID, Int, Double) -> Void)?
|
||||
|
||||
class Coordinator {
|
||||
var headerHostingController: UIHostingController<HeaderContent>?
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> ItineraryTableViewController {
|
||||
let controller = ItineraryTableViewController(style: .plain)
|
||||
// Configure callbacks
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: ItineraryTableViewController, context: Context) {
|
||||
// Push new data to controller
|
||||
controller.reloadData(days: days, ...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Confidence:** HIGH (implemented and working)
|
||||
|
||||
### For External Drops: Callback Extension
|
||||
|
||||
Add a new callback for external drops:
|
||||
|
||||
```swift
|
||||
var onExternalItemDropped: ((ExternalDropItem, Int, Double) -> Void)?
|
||||
// Parameters: dropped item, target day, target sortOrder
|
||||
```
|
||||
|
||||
The ItineraryTableViewController would need to:
|
||||
1. Conform to `UITableViewDropDelegate`
|
||||
2. Set `tableView.dropDelegate = self`
|
||||
3. Implement required delegate methods
|
||||
4. Call the callback when external drop completes
|
||||
|
||||
**Confidence:** HIGH (standard pattern extension)
|
||||
|
||||
---
|
||||
|
||||
## What to Avoid
|
||||
|
||||
### Anti-Pattern 1: SwiftUI-Only Drag-Drop for Complex Reordering
|
||||
|
||||
**What:** Using `.draggable()` / `.dropDestination()` / `.onMove()` directly in SwiftUI List
|
||||
|
||||
**Why Avoid:**
|
||||
- No real-time insertion line feedback during drag (item only moves on drop)
|
||||
- `ForEach.onMove` only works within a single section
|
||||
- Limited control over valid drop positions during drag
|
||||
- iPhone has additional limitations for SwiftUI List drag-drop
|
||||
|
||||
**Evidence:** The codebase documentation explicitly states: "SwiftUI's drag-and-drop APIs have significant limitations for complex reordering"
|
||||
|
||||
**Confidence:** HIGH
|
||||
|
||||
### Anti-Pattern 2: Third-Party Reordering Libraries
|
||||
|
||||
**What:** Using libraries like SwiftReorder, LPRTableView, TableViewDragger
|
||||
|
||||
**Why Avoid:**
|
||||
- Compatibility issues with recent iOS versions reported
|
||||
- Built-in UITableView drag-drop (iOS 11+) is more reliable
|
||||
- Additional dependency for functionality that's native
|
||||
|
||||
**Evidence:** Multiple search results recommend "use the built-in UITableView drag and drop API" over third-party libraries
|
||||
|
||||
**Confidence:** MEDIUM (anecdotal reports)
|
||||
|
||||
### Anti-Pattern 3: Mixing Diffable Data Source with Manual Array Updates
|
||||
|
||||
**What:** Using `UITableViewDiffableDataSource` but manually manipulating the array in `moveRowAt:`
|
||||
|
||||
**Why Avoid:**
|
||||
- Risk of data source inconsistency
|
||||
- Diffable data sources have their own update patterns
|
||||
- The current implementation uses manual `flatItems` array management which works correctly
|
||||
|
||||
**If Using Diffable Data Source:** Must reconcile changes through snapshot mechanism, not direct array manipulation
|
||||
|
||||
**Confidence:** MEDIUM
|
||||
|
||||
### Anti-Pattern 4: Ignoring `localObject` for Same-App Drops
|
||||
|
||||
**What:** Always encoding/decoding NSItemProvider data even for internal drops
|
||||
|
||||
**Why Avoid:**
|
||||
- Unnecessary overhead for same-app transfers
|
||||
- `UIDragItem.localObject` provides direct object access without serialization
|
||||
- More complex code for no benefit
|
||||
|
||||
**Best Practice:** Check `localObject` first, fall back to NSItemProvider decoding only for cross-app drops
|
||||
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
## iOS 26 Considerations
|
||||
|
||||
### New SwiftUI Drag-Drop Modifiers (iOS 26)
|
||||
|
||||
iOS 26 introduces improved SwiftUI drag-drop modifiers:
|
||||
- `.draggable(containerItemID:)` - Marks items as draggable
|
||||
- `.dragContainer(for:selection:)` - Defines container and selection
|
||||
- `.dragConfiguration()` - Controls behavior (allowMove, allowDelete)
|
||||
- `.onDragSessionUpdated()` - Handles drag phases
|
||||
- `.dragPreviewsFormation(.stack)` - Customizes preview
|
||||
|
||||
**Assessment:** These are promising for simpler use cases, particularly macOS file management UIs. However, for the existing UITableView-based itinerary editor:
|
||||
|
||||
**Recommendation:** Keep the UITableView approach. The new SwiftUI modifiers don't provide the same level of control needed for:
|
||||
- Constraint-aware drop validation (travel can only go on certain days)
|
||||
- Real-time insertion line between specific rows
|
||||
- Semantic positioning (day + sortOrder) vs row indices
|
||||
|
||||
**Confidence:** MEDIUM (iOS 26 APIs are new, full capabilities not fully documented)
|
||||
|
||||
### Swift 6 Concurrency Considerations
|
||||
|
||||
The existing `ItineraryTableViewController` is a `final class` (not actor). Key considerations:
|
||||
|
||||
1. **Coordinator should be `@MainActor`** - Delegate callbacks occur on main thread
|
||||
2. **Callbacks are closures** - Already work correctly with Swift 6
|
||||
3. **No async operations during drag** - Validation is synchronous, which is correct
|
||||
|
||||
**No changes required** for Swift 6 compliance in the existing implementation.
|
||||
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decision: Two Approaches for External Drops
|
||||
|
||||
### Option A: Extend Existing UITableViewController (Recommended)
|
||||
|
||||
Add `UITableViewDropDelegate` to `ItineraryTableViewController`:
|
||||
|
||||
```swift
|
||||
extension ItineraryTableViewController: UITableViewDropDelegate {
|
||||
func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
|
||||
// Accept your custom item types
|
||||
return session.canLoadObjects(ofClass: ItineraryItemTransferable.self)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidUpdate session: UIDropSession,
|
||||
withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
|
||||
// Return .insertAtDestinationIndexPath for insertion line feedback
|
||||
return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
|
||||
// Extract item and calculate semantic position
|
||||
// Call onExternalItemDropped callback
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Builds on existing, working implementation
|
||||
- Minimal code changes
|
||||
- Maintains semantic positioning logic
|
||||
|
||||
**Cons:**
|
||||
- None significant
|
||||
|
||||
### Option B: SwiftUI Overlay for Drag Source
|
||||
|
||||
If the external drag SOURCE is a SwiftUI view (e.g., a "suggestions" panel):
|
||||
|
||||
```swift
|
||||
// In SwiftUI
|
||||
SuggestionCard(item: item)
|
||||
.draggable(item) {
|
||||
SuggestionPreview(item: item)
|
||||
}
|
||||
```
|
||||
|
||||
The UITableView receives this via `UITableViewDropDelegate` as above.
|
||||
|
||||
**Note:** This hybrid approach works well - SwiftUI provides the drag source, UIKit receives the drop.
|
||||
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
## Summary: Recommended Stack
|
||||
|
||||
| Component | Recommendation | Rationale |
|
||||
|-----------|---------------|-----------|
|
||||
| **Table View** | `UITableViewController` | Native drag handles, real-time feedback |
|
||||
| **Internal Reorder** | `canMoveRowAt` / `moveRowAt` | Already working, proven |
|
||||
| **External Drops** | Add `UITableViewDropDelegate` | Required for external drops |
|
||||
| **SwiftUI Bridge** | `UIViewControllerRepresentable` | Already working |
|
||||
| **Cell Content** | `UIHostingConfiguration` | SwiftUI views in UIKit cells |
|
||||
| **State Management** | Lifted callbacks to parent | Unidirectional data flow |
|
||||
| **Drag Source (external)** | SwiftUI `.draggable()` | Simple for source views |
|
||||
| **Position Model** | (day, sortOrder) semantics | Already working, robust |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Official Documentation
|
||||
- [UITableViewDragDelegate](https://developer.apple.com/documentation/uikit/uitableviewdragdelegate)
|
||||
- [UITableViewDropDelegate](https://developer.apple.com/documentation/uikit/uitableviewdropdelegate)
|
||||
- [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)
|
||||
- [Adopting drag and drop using SwiftUI](https://developer.apple.com/documentation/SwiftUI/Adopting-drag-and-drop-using-SwiftUI)
|
||||
|
||||
### Technical Articles
|
||||
- [Using Drag and Drop on UITableView for reorder](https://rderik.com/blog/using-drag-and-drop-on-uitableview-for-reorder/)
|
||||
- [Drag to Reorder in UITableView with Diffable Datasource](https://swiftjectivec.com/Tableview-Diffable-Datasource-Drag-to-Reorder/)
|
||||
- [Coding for iOS 11: How to drag & drop into collections & tables](https://hackernoon.com/drag-it-drop-it-in-collection-table-ios-11-6bd28795b313)
|
||||
- [SwiftUI in iOS 26 - What's new from WWDC 2025](https://differ.blog/p/swift-ui-in-ios-26-what-s-new-from-wwdc-2025-819b42)
|
||||
- [Drag and drop transferable data in SwiftUI](https://swiftwithmajid.com/2023/04/05/drag-and-drop-transferable-data-in-swiftui/)
|
||||
|
||||
### SwiftUI Limitations References
|
||||
- [Dragging list rows between sections - Apple Forums](https://developer.apple.com/forums/thread/674393)
|
||||
- [How to let users move rows in a list - Hacking with Swift](https://www.hackingwithswift.com/quick-start/swiftui/how-to-let-users-move-rows-in-a-list)
|
||||
|
||||
---
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Reason |
|
||||
|------|------------|--------|
|
||||
| Core UITableView Drag APIs | HIGH | Stable since iOS 11, extensive documentation |
|
||||
| External Drop via UITableViewDropDelegate | HIGH | Standard documented pattern |
|
||||
| SwiftUI Bridge Pattern | HIGH | Already implemented and working in codebase |
|
||||
| iOS 26 SwiftUI Improvements | MEDIUM | New APIs, limited production experience |
|
||||
| Swift 6 Compatibility | HIGH | Existing code is already compliant |
|
||||
| Third-party library avoidance | MEDIUM | Based on community reports, not direct testing |
|
||||
178
.planning/research/SUMMARY.md
Normal file
178
.planning/research/SUMMARY.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Project Research Summary
|
||||
|
||||
**Project:** SportsTime Itinerary Editor
|
||||
**Domain:** iOS drag-drop reordering with semantic positioning
|
||||
**Researched:** 2026-01-18
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Building a drag-drop itinerary editor for iOS requires bridging two coordinate systems: UITableView's row indices (visual) and the semantic model of (day, sortOrder) (business logic). The SportsTime codebase already contains a working UITableView-based implementation with UIHostingConfiguration for SwiftUI cells. This research validates that approach and identifies the key architectural decision that makes or breaks the feature: **row indices are ephemeral display concerns; semantic positions (day, sortOrder) are the source of truth**.
|
||||
|
||||
The recommended approach extends the existing implementation rather than replacing it. UITableView's native drag-drop APIs (iOS 11+) provide superior UX compared to SwiftUI-only solutions: real-time insertion line feedback, proper scroll-while-dragging, and constraint validation during drag. The existing `canMoveRowAt`/`moveRowAt` pattern handles internal reordering well. For external drops (e.g., from a suggestions panel), add `UITableViewDropDelegate` conformance.
|
||||
|
||||
The critical risks are all related to confusing row indices with semantic positions. Previous attempts failed because travel was treated as a structural day property rather than a positioned item, flattening ignored sortOrder values, and drag logic fought reload logic. The architecture must enforce strict separation: row indices exist only during display, semantic positions exist in the data model, and the bridge between them is recalculated on every flatten operation.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
The existing UIKit + SwiftUI hybrid pattern is correct. UITableView provides the drag-drop infrastructure; SwiftUI provides the cell content through `UIHostingConfiguration`.
|
||||
|
||||
**Core technologies:**
|
||||
- **UITableViewController**: Native drag handles, real-time insertion feedback, proven since iOS 11
|
||||
- **UIHostingConfiguration**: Embeds SwiftUI views in UIKit cells without wrapper hacks
|
||||
- **UITableViewDropDelegate**: Required for accepting external drops (not internal reorders)
|
||||
- **UIViewControllerRepresentable + Coordinator**: Bridge pattern already working in codebase
|
||||
|
||||
**What to avoid:**
|
||||
- SwiftUI-only drag-drop (`.draggable()`, `.dropDestination()`) - lacks insertion line feedback
|
||||
- Third-party reordering libraries - compatibility issues, unnecessary dependency
|
||||
- iOS 26 SwiftUI drag modifiers - promising but not mature enough for complex constraints
|
||||
|
||||
### Expected Features
|
||||
|
||||
**Must have (table stakes):**
|
||||
- Lift animation on grab (shadow + scale)
|
||||
- Ghost/placeholder at original position
|
||||
- Insertion indicator line between items
|
||||
- Items shuffle out of the way (100ms animation)
|
||||
- Magnetic snap on drop
|
||||
- Invalid drop feedback (animate back to origin)
|
||||
- Haptic feedback on grab and drop
|
||||
- Auto-scroll when dragging to viewport edge
|
||||
|
||||
**Should have (polish):**
|
||||
- Slight tilt on drag (Trello-style, 2-3 degrees)
|
||||
- Keyboard reordering for accessibility (VoiceOver actions)
|
||||
- Undo after drop (toast with 5-second timeout)
|
||||
- Drag handle icon (visual affordance)
|
||||
|
||||
**Defer (overkill for itinerary):**
|
||||
- Drag between screens
|
||||
- Multi-item drag with count badge
|
||||
- Physics-based spring animations
|
||||
- Custom drag preview images
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
The architecture uses five layers that cleanly separate concerns. Each layer has a single responsibility, making the system resilient to the frequent reloads from SwiftUI state changes.
|
||||
|
||||
**Major components:**
|
||||
1. **Semantic Position Model** (`ItineraryItem`) - Source of truth with day and sortOrder
|
||||
2. **Constraint Validation** (`ItineraryConstraints`) - Determines valid positions per item type
|
||||
3. **Visual Flattening** - Transforms semantic items into flat row array
|
||||
4. **Drop Slot Calculation** - Translates row indices back to semantic positions
|
||||
5. **Drag Interaction** - UITableView delegate methods with constraint snapping
|
||||
|
||||
**Key pattern:** Midpoint insertion for sortOrder (1.0, 2.0 -> 1.5 -> 1.25 etc.) enables unlimited insertions without renumbering existing items.
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
1. **Row Index vs Semantic Position Confusion** - Never store row indices as positions. Row indices are ephemeral; semantic (day, sortOrder) is persistent. Address in Phase 1 data model.
|
||||
|
||||
2. **Travel as Structural Instead of Positional** - Travel must be an item with its own (day, sortOrder), not a day property like `travelBefore`. Use sortOrder < 0 for "before games" convention.
|
||||
|
||||
3. **Hard-Coded Flatten Order** - Flattening MUST sort by sortOrder within each day. Hard-coding "header, travel, games, custom" ignores sortOrder and breaks reload.
|
||||
|
||||
4. **Data Out of Sync During Drag** - Never call `reloadData()` while drag is active. Guard SwiftUI updates with `draggingItem != nil` flag.
|
||||
|
||||
5. **Coordinate Space Confusion** - UITableView's `targetIndexPath` uses "proposed" coordinates (source row removed). Pre-compute valid destinations in proposed space at drag start.
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
Based on research, suggested phase structure:
|
||||
|
||||
### Phase 1: Semantic Position Model
|
||||
**Rationale:** Everything depends on getting the data model right. Previous failures stemmed from row-based thinking.
|
||||
**Delivers:** `ItineraryItem` with `day: Int` and `sortOrder: Double`, travel as positioned item
|
||||
**Addresses:** Table stakes data representation
|
||||
**Avoids:** Row Index vs Semantic Position Confusion, Travel as Structural pitfalls
|
||||
|
||||
### Phase 2: Constraint Validation Engine
|
||||
**Rationale:** Constraints must be validated semantically, not by row index. Build this before drag interaction.
|
||||
**Delivers:** `ItineraryConstraints` that determines valid positions for games (fixed), travel (bounded), custom (any)
|
||||
**Uses:** Semantic position model from Phase 1
|
||||
**Implements:** Constraint validation layer
|
||||
|
||||
### Phase 3: Visual Flattening
|
||||
**Rationale:** Needs semantic model and constraint awareness. Bridge between model and display.
|
||||
**Delivers:** Deterministic flatten algorithm that sorts by sortOrder, produces flat row array
|
||||
**Addresses:** Hard-coded flatten order pitfall
|
||||
**Implements:** Flattening layer with sortOrder < 0 / >= 0 split
|
||||
|
||||
### Phase 4: Drag Interaction
|
||||
**Rationale:** Depends on all previous layers. This is where UIKit integration happens.
|
||||
**Delivers:** Working drag-drop with constraint snapping, haptics, insertion line
|
||||
**Uses:** UITableViewDragDelegate/DropDelegate, flattening, constraints
|
||||
**Avoids:** Data sync during drag, coordinate space confusion pitfalls
|
||||
|
||||
### Phase 5: Polish and Edge Cases
|
||||
**Rationale:** Core functionality first, polish second.
|
||||
**Delivers:** Lift animation, ghost placeholder, auto-scroll, accessibility actions
|
||||
**Addresses:** All remaining table stakes features
|
||||
|
||||
### Phase 6: External Drops (Optional)
|
||||
**Rationale:** Only if accepting drops from outside the table (e.g., suggestions panel)
|
||||
**Delivers:** `UITableViewDropDelegate` conformance for external items
|
||||
**Uses:** Same constraint validation and drop slot calculation
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
- **Data model first (Phase 1-2):** The architecture analysis identified semantic positioning as the foundation. Constraints depend on semantics, not rows.
|
||||
- **Flatten before drag (Phase 3):** Drag operations call flatten after every move. Getting flatten right prevents the "drag logic vs reload logic" battle.
|
||||
- **Interaction last (Phase 4-6):** UITableView delegate methods are the integration point. They consume all other layers.
|
||||
|
||||
### Research Flags
|
||||
|
||||
Phases likely needing deeper research during planning:
|
||||
- **Phase 4:** Coordinate space translation is subtle. May need prototype to validate proposed vs current index handling.
|
||||
- **Phase 6:** External drops require NSItemProvider/Transferable patterns. Research if implementing.
|
||||
|
||||
Phases with standard patterns (skip research-phase):
|
||||
- **Phase 1-2:** Data modeling is straightforward once semantics are understood.
|
||||
- **Phase 3:** Flattening is deterministic algorithm, well-documented in existing code.
|
||||
- **Phase 5:** Polish features are standard iOS patterns.
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | HIGH | Existing implementation validates approach, APIs stable since iOS 11 |
|
||||
| Features | HIGH | Multiple authoritative UX sources (NN Group, Atlassian, Apple HIG) agree |
|
||||
| Architecture | HIGH | Based on existing working codebase analysis |
|
||||
| Pitfalls | HIGH | Documented previous failures + Apple documentation |
|
||||
|
||||
**Overall confidence:** HIGH
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
- **iOS 26 SwiftUI drag modifiers:** New in WWDC 2025, limited production experience. Assess if they mature enough to replace UIKit approach in future versions.
|
||||
- **Mac Catalyst support:** NSItemProvider quirks noted. Validate if targeting Catalyst.
|
||||
- **sortOrder precision exhaustion:** Theoretical concern after thousands of insertions. Implement normalize function if needed (unlikely in practice).
|
||||
|
||||
## Critical Insight
|
||||
|
||||
**The ONE most important thing:** Row indices are lies. They change constantly as items are added, removed, reordered, and the table flattens. The semantic model (day, sortOrder) is truth. Every previous failure traced back to treating row indices as positions. Every function that touches positions must speak semantic coordinates, converting to/from row indices only at the UITableView boundary.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- 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)
|
||||
- Apple: [UITableViewDragDelegate](https://developer.apple.com/documentation/uikit/uitableviewdragdelegate)
|
||||
- Apple: [UITableViewDropDelegate](https://developer.apple.com/documentation/uikit/uitableviewdropdelegate)
|
||||
- Existing codebase: `ItineraryTableViewController.swift`, `ItineraryTableViewWrapper.swift`, `ItineraryConstraints.swift`
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Smart Interface Design Patterns - Drag and Drop UX](https://smart-interface-design-patterns.com/articles/drag-and-drop-ux/)
|
||||
- [Atlassian Pragmatic Drag and Drop Design Guidelines](https://atlassian.design/components/pragmatic-drag-and-drop/design-guidelines/)
|
||||
- [Nielsen Norman Group - Drag and Drop](https://www.nngroup.com/articles/drag-drop/)
|
||||
- [Apple Human Interface Guidelines - Drag and Drop](https://developer.apple.com/design/human-interface-guidelines/drag-and-drop)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- iOS 26 SwiftUI drag modifiers documentation (new APIs, limited production validation)
|
||||
- Third-party library compatibility reports (community anecdotes)
|
||||
|
||||
---
|
||||
*Research completed: 2026-01-18*
|
||||
*Ready for roadmap: yes*
|
||||
Reference in New Issue
Block a user