Files: - STACK.md - UITableView drag-drop APIs, SwiftUI bridging patterns - FEATURES.md - Table stakes UX (lift, insertion line, haptics), polish features - ARCHITECTURE.md - 5-layer semantic positioning architecture - PITFALLS.md - Critical pitfalls (row vs semantic, travel as structural) - SUMMARY.md - Executive synthesis with roadmap implications Key findings: - Stack: UITableView + UIHostingConfiguration (existing pattern validated) - Architecture: Semantic (day, sortOrder) model, not row indices - Critical pitfall: Row indices are ephemeral; semantic positions are truth Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
16 KiB
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:
- Day-based positioning - Items belong to days, not absolute positions
- Fractional sortOrder - Enables midpoint insertion without renumbering
- 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):
- Day header + Add button (merged into one row)
- Items with sortOrder < 0 (before games)
- Games row (all games bundled)
- 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:
// 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:
- Drag Start: Compute valid destination rows (proposed coordinate space)
- During Drag: Snap to nearest valid position if proposed is invalid
- Drag End: Calculate semantic position, fire callback
Coordinate System Challenge:
UITableView's targetIndexPathForMoveFromRowAt:toProposedIndexPath: uses "proposed" coordinates (array with source row removed). This requires:
// 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
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
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
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:
ItineraryItemstruct withday,sortOrder,kind- Unit tests for fractional sortOrder behavior
No dependencies. This is the foundation.
Phase 2: Constraint Validation
Build:
ItineraryConstraintswith validation rules- Unit tests for travel constraint edge cases
Depends on: Phase 1 (ItineraryItem)
Phase 3: Visual Flattening
Build:
ItineraryRowItemenum (row types)ItineraryDayDatastructure- Flattening algorithm
- Unit tests for row ordering
Depends on: Phase 1 (ItineraryItem)
Phase 4: Drop Slot Calculation
Build:
dayNumber(forRow:)implementationcalculateSortOrder(at:)with midpoint insertion- Unit tests for sortOrder calculation
Depends on: Phase 3 (flattened row array)
Phase 5: Drag Interaction
Build:
targetIndexPathForMoveFromRowAtwith 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:
ItineraryTableViewWrapperbridge- 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:
-
Semantic state is authoritative. SwiftUI's
itineraryItems: [ItineraryItem]is the source of truth. Reloads always regenerate the flat row array from semantic state. -
Flattening is deterministic. Given the same
[ItineraryItem], flattening produces the same[ItineraryRowItem]. No state is stored in the row array. -
Drag callbacks return semantic positions. When drag completes,
onCustomItemMoved(id, day, sortOrder)returns semantic coordinates. The parent updatesitineraryItems, which triggers a reload. -
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:
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:
// 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:
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
ItineraryFlattenertype - 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
- Modern iOS Frontend Architecture (2025)
- SwiftReorder Library - Reference implementation
- Drag and Drop UX Design Best Practices