Route map fixes: swipe-dismiss, filter button, plane scale

Three fixes the user called out:

1. Can't swipe down to dismiss the map screen anymore — the inner
   .sheet for the drawer was eating the parent sheet's
   swipe-to-dismiss gesture. Replaced with a custom overlay drawer
   built from a ZStack + DragGesture, so the map surface is now
   free for the parent sheet's interactive dismiss. Drawer has
   two snap states (peek + expanded), tap the handle or drag up
   to expand, drag down to collapse. Live dragOffset gives
   immediate finger feedback; on release we spring-snap to a state.

2. Filter button added to the map's toolbar. Opens the full
   HistoryFilterSheet so users can edit filters without going
   back to History. Icon switches to .fill variant when any
   filter is active.

3. Plane icons now scale 0 → 1 → 0 across each flight (half-sine
   curve) so they fade in at takeoff, peak at the midpoint, and
   fade out at landing. New FlightSegment.planeScale(at:) returns
   sin(localProgress * π). Scale is applied to both .scaleEffect
   and .opacity so it's a real "appear-and-vanish."

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-29 18:32:48 -05:00
parent f97d5f52ec
commit 572e81406d
+109 -19
View File
@@ -32,7 +32,14 @@ struct HistoryRouteMapView: View {
@State private var animationKey: Int = 0 @State private var animationKey: Int = 0
@State private var schedule: AnimationSchedule = .empty @State private var schedule: AnimationSchedule = .empty
@State private var selectedAirportSheet: AirportSheet? @State private var selectedAirportSheet: AirportSheet?
@State private var sheetDetent: PresentationDetent = .fraction(0.14) @State private var drawerExpanded: Bool = false
@State private var dragOffset: CGFloat = 0
@State private var showingFilterSheet: Bool = false
/// Drawer heights peek shows just the row of stats + replay,
/// expanded shows filter chips + per-airport stats.
private static let drawerPeekHeight: CGFloat = 96
private static let drawerExpandedHeight: CGFloat = 340
/// ~4 second total sweep, snappy. Per-flight slice is computed /// ~4 second total sweep, snappy. Per-flight slice is computed
/// from this in `AnimationSchedule.build`. /// from this in `AnimationSchedule.build`.
@@ -44,12 +51,29 @@ struct HistoryRouteMapView: View {
} }
var body: some View { var body: some View {
ZStack { ZStack(alignment: .bottom) {
mapLayer mapLayer
// Overlay drawer (not a sheet) so the parent sheet's
// swipe-down-to-dismiss gesture still works on the map
// surface. The drawer has two states: peek and expanded.
// Tap or drag the handle to toggle; drag offset gives
// live feedback during the gesture.
mapDrawer
.frame(maxWidth: .infinity)
.frame(height: drawerHeight)
.offset(y: dragOffset)
.gesture(drawerDragGesture)
} }
.navigationTitle(filters.isEmpty ? "Routes" : "Filtered Routes") .navigationTitle(filters.isEmpty ? "Routes" : "Filtered Routes")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) {
Button { showingFilterSheet = true } label: {
Image(systemName: filters.activeCount > 0
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
}
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { restart() } label: { Button { restart() } label: {
Image(systemName: "arrow.clockwise.circle.fill") Image(systemName: "arrow.clockwise.circle.fill")
@@ -58,23 +82,14 @@ struct HistoryRouteMapView: View {
} }
} }
} }
// Recompute the schedule and the camera fit on first appear
// and whenever the filtered flight set changes.
.onAppear { reset() } .onAppear { reset() }
.onChange(of: filters) { _, _ in reset() } .onChange(of: filters) { _, _ in reset() }
// Drive the progress with a Task loop. Re-runs on animationKey
// bump (which restart() triggers).
.task(id: animationKey) { .task(id: animationKey) {
await runAnimation() await runAnimation()
} }
// The drawer is an always-presented sheet with detents the .sheet(isPresented: $showingFilterSheet) {
// user can drag it from peek up to large and back down. HistoryFilterSheet(allFlights: allFlights, filters: $filters)
.sheet(isPresented: .constant(true)) { .presentationDetents([.medium, .large])
mapDrawer
.presentationDetents([.fraction(0.14), .medium, .large], selection: $sheetDetent)
.presentationBackgroundInteraction(.enabled)
.presentationDragIndicator(.visible)
.interactiveDismissDisabled()
} }
.sheet(item: $selectedAirportSheet) { sheet in .sheet(item: $selectedAirportSheet) { sheet in
NavigationStack { NavigationStack {
@@ -91,6 +106,40 @@ struct HistoryRouteMapView: View {
} }
} }
private var drawerHeight: CGFloat {
drawerExpanded ? Self.drawerExpandedHeight : Self.drawerPeekHeight
}
/// Drag gesture for the drawer handle area.
/// - Upward drag expand
/// - Downward drag (from expanded) collapse to peek
/// - dragOffset gives live feedback while the finger is down,
/// then snaps to a state on release with a spring.
private var drawerDragGesture: some Gesture {
DragGesture()
.onChanged { value in
let raw = value.translation.height
// Clamp the live offset so the drawer can't be dragged
// off-screen in either direction.
if drawerExpanded {
dragOffset = max(0, raw)
} else {
dragOffset = min(0, raw)
}
}
.onEnded { value in
let delta = value.translation.height
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
if drawerExpanded {
if delta > 60 { drawerExpanded = false }
} else {
if delta < -40 { drawerExpanded = true }
}
dragOffset = 0
}
}
}
// MARK: - Map // MARK: - Map
private var mapLayer: some View { private var mapLayer: some View {
@@ -111,9 +160,14 @@ struct HistoryRouteMapView: View {
} }
} }
// In-flight plane icons (only the segments currently animating) // In-flight plane icons (only the segments currently animating).
// The plane scales 0 1 0 across its flight: takes off
// tiny, hits full size at the apex of the journey, lands
// small again. Reads like a real plane disappearing into
// the horizon and reappearing.
ForEach(schedule.segments) { seg in ForEach(schedule.segments) { seg in
if let head = seg.head(at: progress) { if let head = seg.head(at: progress) {
let scale = seg.planeScale(at: progress)
Annotation("", coordinate: head.coord) { Annotation("", coordinate: head.coord) {
Image(systemName: "airplane") Image(systemName: "airplane")
.font(.system(size: 16, weight: .black)) .font(.system(size: 16, weight: .black))
@@ -122,6 +176,8 @@ struct HistoryRouteMapView: View {
.background(HistoryStyle.runwayOrange, in: Circle()) .background(HistoryStyle.runwayOrange, in: Circle())
.shadow(color: .black.opacity(0.4), radius: 3, y: 1) .shadow(color: .black.opacity(0.4), radius: 3, y: 1)
.rotationEffect(.degrees(head.bearing - 90)) .rotationEffect(.degrees(head.bearing - 90))
.scaleEffect(scale)
.opacity(scale)
} }
.annotationTitles(.hidden) .annotationTitles(.hidden)
} }
@@ -152,13 +208,38 @@ struct HistoryRouteMapView: View {
@ViewBuilder @ViewBuilder
private var mapDrawer: some View { private var mapDrawer: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
drawerHandle
drawerPeek drawerPeek
if sheetDetent != .fraction(0.14) { if drawerExpanded {
Divider() Divider().opacity(0.5)
drawerExpanded drawerExpandedContent
} }
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.background(
UnevenRoundedRectangle(
topLeadingRadius: 22,
topTrailingRadius: 22
)
.fill(.regularMaterial)
.ignoresSafeArea(edges: .bottom)
)
.shadow(color: .black.opacity(0.15), radius: 10, y: -2)
}
private var drawerHandle: some View {
Capsule()
.fill(.gray.opacity(0.4))
.frame(width: 36, height: 5)
.padding(.top, 8)
.padding(.bottom, 4)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
drawerExpanded.toggle()
}
}
} }
private var drawerPeek: some View { private var drawerPeek: some View {
@@ -204,7 +285,7 @@ struct HistoryRouteMapView: View {
} }
@ViewBuilder @ViewBuilder
private var drawerExpanded: some View { private var drawerExpandedContent: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
if !filters.years.isEmpty || !filters.airlines.isEmpty || !filters.airports.isEmpty { if !filters.years.isEmpty || !filters.airlines.isEmpty || !filters.airports.isEmpty {
Text("FILTERS") Text("FILTERS")
@@ -502,6 +583,15 @@ struct FlightSegment: Identifiable, Hashable {
return Head(coord: here, bearing: bearing(from: here, to: next)) return Head(coord: here, bearing: bearing(from: here, to: next))
} }
/// Plane scale across the flight 0 at takeoff, 1.0 at the
/// midpoint, 0 at landing. Half-sine curve. Returns 0 when the
/// flight isn't currently animating.
func planeScale(at progress: Double) -> CGFloat {
guard progress >= startProgress, progress < endProgress else { return 0 }
let local = (progress - startProgress) / max(0.0001, endProgress - startProgress)
return CGFloat(sin(local * .pi))
}
private func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double { private func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
let lat1 = a.latitude * .pi / 180 let lat1 = a.latitude * .pi / 180
let lat2 = b.latitude * .pi / 180 let lat2 = b.latitude * .pi / 180