# 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? 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? } ``` ### 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)