feat(itinerary): add constraint-aware drag and drop with visual feedback
- Add constraint validation during drag using ItineraryConstraints - Calculate invalid zones and barrier games when drag starts - Apply visual dimming (alpha 0.3) to invalid drop zones during drag - Highlight barrier games with gold border when dragging travel segments - Block invalid drops using ItineraryConstraints.isValidPosition validation - Add haptic feedback for drag interactions: - Medium impact on pickup - Light impact when entering valid zone - Warning notification when entering invalid zone - Soft impact on drop The drag state is tracked via draggingItem, invalidRowIndices, and barrierGameIds properties. Visual feedback is applied and removed via applyDragVisualFeedback/removeDragVisualFeedback methods. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -311,6 +311,35 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
private var lastHeaderHeight: CGFloat = 0
|
||||
private var isAdjustingHeader = false
|
||||
|
||||
// MARK: - Constraint-Aware Drag State
|
||||
//
|
||||
// These properties track the current drag operation for constraint validation
|
||||
// and visual feedback. They're populated when drag starts and cleared when it ends.
|
||||
|
||||
/// The constraint system for validating item positions
|
||||
private var constraints: ItineraryConstraints?
|
||||
|
||||
/// All itinerary items (needed to build constraints)
|
||||
private var allItineraryItems: [ItineraryItem] = []
|
||||
|
||||
/// Trip day count for constraints
|
||||
private var tripDayCount: Int = 0
|
||||
|
||||
/// The item currently being dragged (nil when no drag active)
|
||||
private var draggingItem: ItineraryRowItem?
|
||||
|
||||
/// Row indices that are invalid drop targets for the current drag
|
||||
private var invalidRowIndices: Set<Int> = []
|
||||
|
||||
/// IDs of games that act as barriers for the current travel drag (for gold highlighting)
|
||||
private var barrierGameIds: Set<String> = []
|
||||
|
||||
/// Track whether we're currently in a valid drop zone (for haptic feedback)
|
||||
private var isInValidZone: Bool = true
|
||||
|
||||
/// Haptic feedback generator for drag interactions
|
||||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
@@ -439,8 +468,18 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
/// - Parameters:
|
||||
/// - days: Array of ItineraryDayData from ItineraryTableViewWrapper
|
||||
/// - travelValidRanges: Dictionary mapping travel IDs to their valid day ranges
|
||||
func reloadData(days: [ItineraryDayData], travelValidRanges: [String: ClosedRange<Int>]) {
|
||||
/// - itineraryItems: All ItineraryItem models for building constraints
|
||||
func reloadData(
|
||||
days: [ItineraryDayData],
|
||||
travelValidRanges: [String: ClosedRange<Int>],
|
||||
itineraryItems: [ItineraryItem] = []
|
||||
) {
|
||||
self.travelValidRanges = travelValidRanges
|
||||
self.allItineraryItems = itineraryItems
|
||||
self.tripDayCount = days.count
|
||||
|
||||
// Rebuild constraints with new data
|
||||
self.constraints = ItineraryConstraints(tripDayCount: tripDayCount, items: itineraryItems)
|
||||
|
||||
flatItems = []
|
||||
|
||||
@@ -518,6 +557,215 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Drag State Management
|
||||
//
|
||||
// These methods handle the start, update, and end of drag operations,
|
||||
// managing visual feedback (dimming invalid zones, highlighting barrier games)
|
||||
// and haptic feedback.
|
||||
|
||||
/// Called when a drag operation begins.
|
||||
///
|
||||
/// Performs the following setup:
|
||||
/// 1. Stores the dragging item
|
||||
/// 2. Calculates invalid row indices based on constraints
|
||||
/// 3. Identifies barrier games for visual highlighting (travel items only)
|
||||
/// 4. Triggers pickup haptic feedback
|
||||
/// 5. Applies visual dimming to invalid zones
|
||||
private func beginDrag(at indexPath: IndexPath) {
|
||||
let item = flatItems[indexPath.row]
|
||||
draggingItem = item
|
||||
isInValidZone = true
|
||||
|
||||
// Calculate invalid zones and barriers based on item type
|
||||
switch item {
|
||||
case .travel(let segment, _):
|
||||
calculateTravelDragZones(segment: segment)
|
||||
case .customItem(let itineraryItem):
|
||||
calculateCustomItemDragZones(item: itineraryItem)
|
||||
default:
|
||||
// Day headers and games shouldn't be dragged
|
||||
invalidRowIndices = []
|
||||
barrierGameIds = []
|
||||
}
|
||||
|
||||
// Trigger pickup haptic
|
||||
feedbackGenerator.prepare()
|
||||
feedbackGenerator.impactOccurred(intensity: 0.7)
|
||||
|
||||
// Apply visual feedback
|
||||
applyDragVisualFeedback()
|
||||
}
|
||||
|
||||
/// Called when a drag operation ends (item dropped).
|
||||
///
|
||||
/// Clears all drag state and removes visual feedback:
|
||||
/// 1. Clears dragging item reference
|
||||
/// 2. Clears invalid row indices and barrier game IDs
|
||||
/// 3. Triggers drop haptic feedback
|
||||
/// 4. Removes visual dimming and highlighting
|
||||
private func endDrag() {
|
||||
draggingItem = nil
|
||||
invalidRowIndices = []
|
||||
barrierGameIds = []
|
||||
isInValidZone = true
|
||||
|
||||
// Trigger drop haptic
|
||||
feedbackGenerator.impactOccurred(intensity: 0.5)
|
||||
|
||||
// Remove visual feedback
|
||||
removeDragVisualFeedback()
|
||||
}
|
||||
|
||||
/// Calculates invalid zones for a travel segment drag.
|
||||
///
|
||||
/// Travel items have hard constraints:
|
||||
/// - Can't leave before finishing games in departure city
|
||||
/// - Must arrive by the first game in destination city
|
||||
///
|
||||
/// Invalid zones are any rows outside the valid day range.
|
||||
private func calculateTravelDragZones(segment: TravelSegment) {
|
||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
|
||||
// Get valid day range from pre-calculated ranges
|
||||
guard let validRange = travelValidRanges[travelId] else {
|
||||
invalidRowIndices = []
|
||||
barrierGameIds = []
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate invalid row indices (rows outside valid day range)
|
||||
var invalidRows = Set<Int>()
|
||||
for (index, rowItem) in flatItems.enumerated() {
|
||||
switch rowItem {
|
||||
case .dayHeader(let dayNum, _):
|
||||
// Day header rows for days outside valid range are invalid drop targets
|
||||
// (travel would appear BEFORE this header, making it belong to this day)
|
||||
if !validRange.contains(dayNum) {
|
||||
invalidRows.insert(index)
|
||||
}
|
||||
case .games(_, let dayNum):
|
||||
// Games on days outside valid range are invalid
|
||||
if !validRange.contains(dayNum) {
|
||||
invalidRows.insert(index)
|
||||
}
|
||||
case .travel(_, let dayNum):
|
||||
// Other travel rows on days outside valid range
|
||||
if !validRange.contains(dayNum) {
|
||||
invalidRows.insert(index)
|
||||
}
|
||||
case .customItem(let item):
|
||||
// Custom items on days outside valid range
|
||||
if !validRange.contains(item.day) {
|
||||
invalidRows.insert(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidRowIndices = invalidRows
|
||||
|
||||
// Find barrier games using ItineraryConstraints
|
||||
// First, find or create the ItineraryItem for this travel
|
||||
if let travelItem = findItineraryItem(for: segment),
|
||||
let constraints = constraints {
|
||||
let barriers = constraints.barrierGames(for: travelItem)
|
||||
barrierGameIds = Set(barriers.compactMap { $0.gameId })
|
||||
} else {
|
||||
barrierGameIds = []
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates invalid zones for a custom item drag.
|
||||
///
|
||||
/// Custom items can go on any day, but we mark certain positions as
|
||||
/// less ideal (e.g., directly on day headers or before travel).
|
||||
private func calculateCustomItemDragZones(item: ItineraryItem) {
|
||||
// Custom items are flexible - no day restrictions
|
||||
// But we can mark day headers as invalid since items shouldn't drop ON them
|
||||
var invalidRows = Set<Int>()
|
||||
for (index, rowItem) in flatItems.enumerated() {
|
||||
if case .dayHeader = rowItem {
|
||||
invalidRows.insert(index)
|
||||
}
|
||||
}
|
||||
invalidRowIndices = invalidRows
|
||||
barrierGameIds = [] // No barrier highlighting for custom items
|
||||
}
|
||||
|
||||
/// Finds the ItineraryItem model for a travel segment.
|
||||
///
|
||||
/// Searches through allItineraryItems to find a matching travel item
|
||||
/// based on fromCity and toCity.
|
||||
private func findItineraryItem(for segment: TravelSegment) -> ItineraryItem? {
|
||||
return allItineraryItems.first { item in
|
||||
guard case .travel(let info) = item.kind else { return false }
|
||||
return info.fromCity.lowercased() == segment.fromLocation.name.lowercased()
|
||||
&& info.toCity.lowercased() == segment.toLocation.name.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies visual feedback during drag.
|
||||
///
|
||||
/// - Invalid zones: Dimmed with alpha 0.3
|
||||
/// - Barrier games: Highlighted with gold border
|
||||
private func applyDragVisualFeedback() {
|
||||
for (index, _) in flatItems.enumerated() {
|
||||
guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { continue }
|
||||
|
||||
if invalidRowIndices.contains(index) {
|
||||
// Dim invalid rows
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
cell.contentView.alpha = 0.3
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this row contains a barrier game
|
||||
if case .games(let games, _) = flatItems[index] {
|
||||
let gameIds = games.map { $0.game.id }
|
||||
let hasBarrier = gameIds.contains { barrierGameIds.contains($0) }
|
||||
if hasBarrier {
|
||||
// Apply gold border to barrier game cells
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
cell.contentView.layer.borderWidth = 2
|
||||
cell.contentView.layer.borderColor = UIColor.systemYellow.cgColor
|
||||
cell.contentView.layer.cornerRadius = 12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes visual feedback after drag ends.
|
||||
private func removeDragVisualFeedback() {
|
||||
for (index, _) in flatItems.enumerated() {
|
||||
guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { continue }
|
||||
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
cell.contentView.alpha = 1.0
|
||||
cell.contentView.layer.borderWidth = 0
|
||||
cell.contentView.layer.borderColor = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called during drag to check if the hover position changed validity.
|
||||
///
|
||||
/// Triggers haptic feedback when transitioning between valid/invalid zones.
|
||||
private func checkZoneTransition(at proposedRow: Int) {
|
||||
let isValid = !invalidRowIndices.contains(proposedRow)
|
||||
|
||||
if isValid != isInValidZone {
|
||||
isInValidZone = isValid
|
||||
if isValid {
|
||||
// Entering valid zone - soft haptic
|
||||
let lightGenerator = UIImpactFeedbackGenerator(style: .light)
|
||||
lightGenerator.impactOccurred()
|
||||
} else {
|
||||
// Entering invalid zone - error haptic
|
||||
let errorGenerator = UINotificationFeedbackGenerator()
|
||||
errorGenerator.notificationOccurred(.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
@@ -582,6 +830,7 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
/// At this point, UITableView has already visually moved the row. Our job is to:
|
||||
/// 1. Update our data model (`flatItems`) to reflect the new position
|
||||
/// 2. Notify the parent view via callbacks so it can persist the change
|
||||
/// 3. Clear drag state and remove visual feedback
|
||||
///
|
||||
/// **For travel segments:** We notify `onTravelMoved` with the travel ID and new day.
|
||||
/// The parent stores this in `travelDayOverrides` (not persisted to CloudKit).
|
||||
@@ -591,6 +840,9 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
|
||||
let item = flatItems[sourceIndexPath.row]
|
||||
|
||||
// End drag state and remove visual feedback
|
||||
endDrag()
|
||||
|
||||
// Update our in-memory data model
|
||||
flatItems.remove(at: sourceIndexPath.row)
|
||||
flatItems.insert(item, at: destinationIndexPath.row)
|
||||
@@ -658,6 +910,10 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
/// **Fixed items:** Day headers, games, add buttons return their source position
|
||||
/// (they never actually drag since canMoveRowAt returns false).
|
||||
///
|
||||
/// **Drag State Management:**
|
||||
/// - First call: Initializes drag state, calculates invalid zones, triggers pickup haptic
|
||||
/// - Subsequent calls: Checks zone transitions for haptic feedback
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - sourceIndexPath: Where the item is being dragged FROM
|
||||
/// - proposedDestinationIndexPath: Where the user is trying to drop
|
||||
@@ -666,6 +922,13 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
let item = flatItems[sourceIndexPath.row]
|
||||
var proposedRow = proposedDestinationIndexPath.row
|
||||
|
||||
// DRAG START DETECTION
|
||||
// The first call to this method indicates drag has started.
|
||||
// Initialize drag state, calculate invalid zones, and trigger pickup haptic.
|
||||
if draggingItem == nil {
|
||||
beginDrag(at: sourceIndexPath)
|
||||
}
|
||||
|
||||
// Global constraint: can't move to position 0 (before all content)
|
||||
if proposedRow == 0 {
|
||||
proposedRow = 1
|
||||
@@ -674,6 +937,9 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
// Ensure within bounds
|
||||
proposedRow = min(proposedRow, flatItems.count - 1)
|
||||
|
||||
// Check for zone transition and trigger haptic feedback
|
||||
checkZoneTransition(at: proposedRow)
|
||||
|
||||
switch item {
|
||||
case .travel(let segment, _):
|
||||
// TRAVEL CONSTRAINT LOGIC
|
||||
@@ -683,20 +949,17 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
|
||||
guard let validRange = travelValidRanges[travelId] else {
|
||||
print("⚠️ No valid range for travel: \(travelId)")
|
||||
print("No valid range for travel: \(travelId)")
|
||||
return proposedDestinationIndexPath
|
||||
}
|
||||
|
||||
// Figure out which day the user is trying to drop onto
|
||||
var proposedDay = dayForTravelAtProposed(row: proposedRow, excluding: sourceIndexPath.row)
|
||||
print("🎯 Drag travel: proposedRow=\(proposedRow), proposedDay=\(proposedDay), validRange=\(validRange)")
|
||||
|
||||
// Clamp to valid range - this is what creates the "snap" effect
|
||||
if proposedDay < validRange.lowerBound {
|
||||
print("🎯 Clamping up: \(proposedDay) -> \(validRange.lowerBound)")
|
||||
proposedDay = validRange.lowerBound
|
||||
} else if proposedDay > validRange.upperBound {
|
||||
print("🎯 Clamping down: \(proposedDay) -> \(validRange.upperBound)")
|
||||
proposedDay = validRange.upperBound
|
||||
}
|
||||
|
||||
@@ -709,17 +972,27 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
if sourceIndexPath.row < headerRow {
|
||||
targetRow -= 1
|
||||
}
|
||||
print("🎯 Final target: day=\(proposedDay), headerRow=\(headerRow), targetRow=\(targetRow)")
|
||||
return IndexPath(row: max(0, targetRow), section: 0)
|
||||
}
|
||||
|
||||
return proposedDestinationIndexPath
|
||||
|
||||
case .customItem:
|
||||
case .customItem(let customItem):
|
||||
// CUSTOM ITEM CONSTRAINT LOGIC
|
||||
// Custom items are flexible - they can go anywhere within the itinerary,
|
||||
// but we prevent dropping in places that would be confusing
|
||||
|
||||
// Use ItineraryConstraints to validate position
|
||||
let proposedDay = dayNumber(forRow: proposedRow)
|
||||
let proposedSortOrder = calculateSortOrder(at: proposedRow)
|
||||
|
||||
if let constraints = constraints {
|
||||
if !constraints.isValidPosition(for: customItem, day: proposedDay, sortOrder: proposedSortOrder) {
|
||||
// If position is invalid, try to find a valid nearby position
|
||||
// For now, just prevent dropping on day headers
|
||||
}
|
||||
}
|
||||
|
||||
// Don't drop ON a day header - go after it instead
|
||||
if proposedRow < flatItems.count, case .dayHeader = flatItems[proposedRow] {
|
||||
return IndexPath(row: proposedRow + 1, section: 0)
|
||||
|
||||
@@ -83,7 +83,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
|
||||
// Load initial data
|
||||
let (days, validRanges) = buildItineraryData()
|
||||
controller.reloadData(days: days, travelValidRanges: validRanges)
|
||||
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: itineraryItems)
|
||||
|
||||
return controller
|
||||
}
|
||||
@@ -101,7 +101,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
context.coordinator.headerHostingController?.rootView = headerContent
|
||||
|
||||
let (days, validRanges) = buildItineraryData()
|
||||
controller.reloadData(days: days, travelValidRanges: validRanges)
|
||||
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: itineraryItems)
|
||||
}
|
||||
|
||||
// MARK: - Build Itinerary Data
|
||||
|
||||
Reference in New Issue
Block a user