Live tab: hardening pass for smooth, snappy feel

Targeted every hitch in the live-tracker flow:

1) Async DB loading. AircraftDatabase (1.5MB JSON) and
   AircraftRegistry (200KB JSON) were parsing synchronously on the
   main thread the first time anything touched them — typically when
   the user first opened the Live tab. Both now bootstrap with a
   safe fallback in init() and parse the full JSON on a background
   Task at app launch (FlightsApp.init calls preload() on both).
   Reads are NSLock-guarded with lock.withLock {} (async-safe).

2) Crossfade suppression on refresh. The 15s auto-refresh swaps the
   `aircraft` array wholesale, which made SwiftUI try to crossfade
   every annotation. Wrapped the assignment in a Transaction with
   disablesAnimations = true so the swap is instant.

3) Cached filtered aircraft. `filteredAircraft` was a computed
   property running through every aircraft on every body re-render
   (e.g. while a sheet was animating in). Moved to @State,
   recomputed via .onChange handlers on each dependency.

4) Lighter pin view. AircraftPin no longer carries the full
   LiveAircraft struct or contains a conditional ZStack — just the
   minimal {tint, rotation, isSelected} props, conforms to
   Equatable so SwiftUI can skip diffing identical pins, and uses
   .animation(nil) on the tint to prevent color crossfades during
   refresh.

5) Coarse relative-time bucketing. The footer's "updated 5s ago"
   text was ticking every second, which dirtied the footer subtree
   on every body pass. Now snaps to {just now, <30s ago, <1m ago,
   Nm ago} — no second-by-second ticks.

Net effect: tab opening is instant (DBs are warm), refreshes don't
flicker, filter sheet animation is smooth, map panning isn't fighting
view-tree rebuilds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-27 07:27:26 -05:00
parent 390a158487
commit de7a70b198
4 changed files with 169 additions and 79 deletions
+6
View File
@@ -11,6 +11,12 @@ struct FlightsApp: App {
let db = AirportDatabase()
self.database = db
self.loadService = AirlineLoadService(airportDatabase: db)
// Pre-load the bundled airline + aircraft databases on a background
// thread. Both are large enough (200KB and 1.5MB) to noticeably
// jank the UI if we wait until first access on the Live tab.
AircraftRegistry.shared.preload()
AircraftDatabase.shared.preload()
}
var body: some Scene {
+34 -9
View File
@@ -14,22 +14,47 @@ import Foundation
final class AircraftDatabase: @unchecked Sendable {
static let shared = AircraftDatabase()
private let byICAO24: [String: String]
/// Storage. Empty until `preload()` has filled it. Reads before preload
/// return nil the type-code filter will simply look empty until the
/// DB has loaded, instead of blocking the main thread for ~100-200ms
/// while parsing 1.5MB of JSON.
private let lock = NSLock()
private var byICAO24: [String: String] = [:]
private var isLoaded: Bool = false
private init() {
if let url = Bundle.main.url(forResource: "aircraftDB", withExtension: "json"),
let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([String: String].self, from: data) {
byICAO24 = decoded
} else {
byICAO24 = [:]
private init() {}
/// Kick off the JSON parse on a background thread. Safe to call
/// multiple times subsequent calls are no-ops. Call this once at
/// app launch (FlightsApp.init) so the data is ready by the time
/// the user opens the Live tab.
func preload() {
lock.lock()
if isLoaded {
lock.unlock()
return
}
isLoaded = true
lock.unlock()
Task.detached(priority: .utility) { [weak self] in
guard let url = Bundle.main.url(forResource: "aircraftDB", withExtension: "json"),
let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([String: String].self, from: data)
else { return }
self?.lock.withLock {
self?.byICAO24 = decoded
}
}
}
/// Returns the ICAO aircraft type designator for the given 24-bit
/// ICAO transponder address, or nil if the airframe isn't in the DB.
/// ICAO transponder address, or nil if the airframe isn't in the DB
/// (or the DB hasn't finished loading yet).
func typeCode(forICAO24 icao24: String) -> String? {
let key = icao24.lowercased()
lock.lock()
defer { lock.unlock() }
return byICAO24[key]
}
+48 -26
View File
@@ -20,38 +20,60 @@ final class AircraftRegistry: @unchecked Sendable {
let logoURL: URL? // FR24 CDN URL, may be nil
}
private let byICAO: [String: Entry]
private let byIATA: [String: Entry]
private let lock = NSLock()
private var byICAO: [String: Entry] = [:]
private var byIATA: [String: Entry] = [:]
private var isLoaded = false
private init() {
let url = Bundle.main.url(forResource: "airlines", withExtension: "json")
guard let url, let data = try? Data(contentsOf: url),
let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
// Fallback to hardcoded subset if the bundled file is missing.
byICAO = Self.builtIn.reduce(into: [:]) { acc, kv in
acc[kv.key] = Entry(icao: kv.key, iata: kv.value.0, name: kv.value.1, logoURL: nil)
}
byIATA = byICAO.values.reduce(into: [:]) { acc, e in
if let i = e.iata { acc[i] = e }
}
// Bootstrap with the hardcoded fallback so reads never come back
// empty even before preload finishes.
var icao: [String: Entry] = [:]
var iata: [String: Entry] = [:]
for (code, info) in Self.builtIn {
let e = Entry(icao: code, iata: info.0, name: info.1, logoURL: nil)
icao[code] = e
iata[info.0] = e
}
byICAO = icao
byIATA = iata
}
/// Kick off the airlines JSON parse on a background thread. Safe to
/// call multiple times. Call at app launch so we never hit the parse
/// cost when the user opens the Live tab.
func preload() {
lock.lock()
if isLoaded {
lock.unlock()
return
}
isLoaded = true
lock.unlock()
var icaoMap: [String: Entry] = [:]
var iataMap: [String: Entry] = [:]
for row in raw {
let icao = (row["i"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil
let iata = (row["a"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil
let name = row["n"] as? String ?? ""
let logo = (row["l"] as? String).flatMap(URL.init(string:))
guard !name.isEmpty else { continue }
let entry = Entry(icao: icao, iata: iata, name: name, logoURL: logo)
if let icao { icaoMap[icao.uppercased()] = entry }
if let iata { iataMap[iata.uppercased()] = entry }
Task.detached(priority: .utility) { [weak self] in
guard let url = Bundle.main.url(forResource: "airlines", withExtension: "json"),
let data = try? Data(contentsOf: url),
let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else { return }
var icaoMap: [String: Entry] = [:]
var iataMap: [String: Entry] = [:]
for row in raw {
let icao = (row["i"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil
let iata = (row["a"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil
let name = row["n"] as? String ?? ""
let logo = (row["l"] as? String).flatMap(URL.init(string:))
guard !name.isEmpty else { continue }
let entry = Entry(icao: icao, iata: iata, name: name, logoURL: logo)
if let icao { icaoMap[icao.uppercased()] = entry }
if let iata { iataMap[iata.uppercased()] = entry }
}
self?.lock.withLock {
self?.byICAO = icaoMap
self?.byIATA = iataMap
}
}
byICAO = icaoMap
byIATA = iataMap
}
/// Look up by ICAO 3-letter prefix (e.g. "DAL").
+81 -44
View File
@@ -63,6 +63,11 @@ struct LiveFlightsView: View {
@State private var cachedAirlineItems: [AirlineFilterItem] = []
@State private var cachedTypeItems: [TypeFilterItem] = []
/// Pre-filtered aircraft snapshot. Recomputed only when `aircraft` or
/// any filter state changes. Caching here avoids running the filter
/// loop on every body re-render (e.g. when a sheet animates in).
@State private var cachedFilteredAircraft: [LiveAircraft] = []
/// Tracks the last bounding box we fetched against. Used to throttle
/// the on-pan refresh so that micro-camera-settlements (which happen
/// every time annotations re-render) don't fire fresh OpenSky calls.
@@ -101,7 +106,15 @@ struct LiveFlightsView: View {
.task(id: selectedAircraft?.icao24) {
await loadTrackForSelection()
}
.onChange(of: aircraft) { _, _ in rebuildFilterItems() }
.onChange(of: aircraft) { _, _ in
rebuildFilterItems()
rebuildFilteredAircraft()
}
.onChange(of: selectedAirlineICAO) { _, _ in rebuildFilteredAircraft() }
.onChange(of: selectedTypeCodes) { _, _ in rebuildFilteredAircraft() }
.onChange(of: selectedAltitudeBand) { _, _ in rebuildFilteredAircraft() }
.onChange(of: hideOnGround) { _, _ in rebuildFilteredAircraft() }
.onChange(of: searchText) { _, _ in rebuildFilteredAircraft() }
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .aircraft(let ac):
@@ -145,8 +158,13 @@ struct LiveFlightsView: View {
}
ForEach(filteredAircraft) { ac in
// Pre-compute the pin's inputs outside the closure so the
// SwiftUI builder doesn't re-derive them on every diff.
let tint = aircraftTint(for: ac)
let rotation = Double(ac.heading ?? 0) - 45 // SF airplane symbol points up-right
let selected = selectedAircraft?.id == ac.id
Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) {
AircraftPin(ac: ac, isSelected: selectedAircraft?.id == ac.id)
AircraftPin(tint: tint, headingMinus45: rotation, isSelected: selected)
.onTapGesture {
activeSheet = .aircraft(ac)
}
@@ -332,23 +350,26 @@ struct LiveFlightsView: View {
// MARK: - Derived
private var filteredAircraft: [LiveAircraft] {
/// Quick read accessor used by the rendering layer. Always returns the
/// cached snapshot; rebuilds happen via the .onChange handlers above.
private var filteredAircraft: [LiveAircraft] { cachedFilteredAircraft }
private func rebuildFilteredAircraft() {
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
return aircraft.filter { ac in
if hideOnGround && ac.onGround { return false }
if !selectedAirlineICAO.isEmpty {
// Tightened: require an airline match. Aircraft without a
// parseable ICAO airline prefix (N-numbers, ad-hoc callsigns)
// were slipping through as "uncategorized" that's why
// selecting Southwest still left other carriers on screen.
guard let code = ac.airlineICAO, selectedAirlineICAO.contains(code) else {
return false
}
let airlines = selectedAirlineICAO
let types = selectedTypeCodes
let band = selectedAltitudeBand
let hideGround = hideOnGround
cachedFilteredAircraft = aircraft.filter { ac in
if hideGround && ac.onGround { return false }
if !airlines.isEmpty {
guard let code = ac.airlineICAO, airlines.contains(code) else { return false }
}
if !selectedTypeCodes.isEmpty {
guard let tc = ac.typeCode, selectedTypeCodes.contains(tc) else { return false }
if !types.isEmpty {
guard let tc = ac.typeCode, types.contains(tc) else { return false }
}
if let band = selectedAltitudeBand {
if let band {
guard let alt = ac.altitudeFeet, band.contains(alt) else { return false }
}
if !s.isEmpty {
@@ -471,7 +492,13 @@ struct LiveFlightsView: View {
let results = try await openSky.states(
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
)
aircraft = results
// Suppress the implicit crossfade SwiftUI would apply to the
// map annotations when the underlying array swaps wholesale.
var tx = Transaction()
tx.disablesAnimations = true
withTransaction(tx) {
aircraft = results
}
lastFetchAt = Date()
lastFetchedBoundingBox = bb
error = nil
@@ -519,46 +546,56 @@ struct LiveFlightsView: View {
private func relativeTime(_ d: Date) -> String {
let secs = Int(Date().timeIntervalSince(d))
// Snap to coarse buckets so the footer text doesn't tick every
// second (which would force the footer subtree to re-render
// every body pass that happens to land on a different second).
if secs < 5 { return "just now" }
if secs < 60 { return "\(secs)s ago" }
if secs < 30 { return "<30s ago" }
if secs < 60 { return "<1m ago" }
return "\(secs / 60)m ago"
}
}
// MARK: - Aircraft pin
private struct AircraftPin: View {
let ac: LiveAircraft
/// Per-aircraft map pin. Kept deliberately minimal every annotation is
/// a SwiftUI view in the map's content tree, so view-tree depth × N pins
/// directly affects scroll/pan performance. Equatable so SwiftUI can
/// skip diffing identical pins on re-renders.
private struct AircraftPin: View, Equatable {
let tint: Color
let headingMinus45: Double
let isSelected: Bool
var body: some View {
ZStack {
if isSelected {
Image(systemName: "airplane")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.padding(6)
.background(Circle().fill(tint))
.overlay(
Circle()
.fill(FlightTheme.accent.opacity(0.25))
.frame(width: 36, height: 36)
}
Image(systemName: "airplane")
.font(.system(size: isSelected ? 18 : 14, weight: .bold))
.foregroundStyle(.white)
.padding(6)
.background(
Circle().fill(tint)
)
.rotationEffect(.degrees(Double(ac.heading ?? 0) - 45))
// SF Symbol "airplane" points up-and-right by default;
// -45° aligns it to true north before applying the heading.
}
.contentShape(Rectangle())
.stroke(.white, lineWidth: isSelected ? 2.5 : 0)
)
.rotationEffect(.degrees(headingMinus45))
.scaleEffect(isSelected ? 1.3 : 1)
.animation(nil, value: tint)
.contentShape(Rectangle())
}
private var tint: Color {
if ac.onGround { return FlightTheme.textTertiary }
switch ac.verticalState {
case .climbing: return FlightTheme.onTime
case .descending: return FlightTheme.delayed
case .level: return FlightTheme.accent
}
static func == (lhs: AircraftPin, rhs: AircraftPin) -> Bool {
lhs.tint == rhs.tint
&& lhs.headingMinus45 == rhs.headingMinus45
&& lhs.isSelected == rhs.isSelected
}
}
private func aircraftTint(for ac: LiveAircraft) -> Color {
if ac.onGround { return FlightTheme.textTertiary }
switch ac.verticalState {
case .climbing: return FlightTheme.onTime
case .descending: return FlightTheme.delayed
case .level: return FlightTheme.accent
}
}