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:
@@ -11,6 +11,12 @@ struct FlightsApp: App {
|
|||||||
let db = AirportDatabase()
|
let db = AirportDatabase()
|
||||||
self.database = db
|
self.database = db
|
||||||
self.loadService = AirlineLoadService(airportDatabase: 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 {
|
var body: some Scene {
|
||||||
|
|||||||
@@ -14,22 +14,47 @@ import Foundation
|
|||||||
final class AircraftDatabase: @unchecked Sendable {
|
final class AircraftDatabase: @unchecked Sendable {
|
||||||
static let shared = AircraftDatabase()
|
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() {
|
private init() {}
|
||||||
if let url = Bundle.main.url(forResource: "aircraftDB", withExtension: "json"),
|
|
||||||
|
/// 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 data = try? Data(contentsOf: url),
|
||||||
let decoded = try? JSONDecoder().decode([String: String].self, from: data) {
|
let decoded = try? JSONDecoder().decode([String: String].self, from: data)
|
||||||
byICAO24 = decoded
|
else { return }
|
||||||
} else {
|
self?.lock.withLock {
|
||||||
byICAO24 = [:]
|
self?.byICAO24 = decoded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the ICAO aircraft type designator for the given 24-bit
|
/// 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? {
|
func typeCode(forICAO24 icao24: String) -> String? {
|
||||||
let key = icao24.lowercased()
|
let key = icao24.lowercased()
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
return byICAO24[key]
|
return byICAO24[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,23 +20,42 @@ final class AircraftRegistry: @unchecked Sendable {
|
|||||||
let logoURL: URL? // FR24 CDN URL, may be nil
|
let logoURL: URL? // FR24 CDN URL, may be nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private let byICAO: [String: Entry]
|
private let lock = NSLock()
|
||||||
private let byIATA: [String: Entry]
|
private var byICAO: [String: Entry] = [:]
|
||||||
|
private var byIATA: [String: Entry] = [:]
|
||||||
|
private var isLoaded = false
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
let url = Bundle.main.url(forResource: "airlines", withExtension: "json")
|
// Bootstrap with the hardcoded fallback so reads never come back
|
||||||
guard let url, let data = try? Data(contentsOf: url),
|
// empty even before preload finishes.
|
||||||
let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
var icao: [String: Entry] = [:]
|
||||||
else {
|
var iata: [String: Entry] = [:]
|
||||||
// Fallback to hardcoded subset if the bundled file is missing.
|
for (code, info) in Self.builtIn {
|
||||||
byICAO = Self.builtIn.reduce(into: [:]) { acc, kv in
|
let e = Entry(icao: code, iata: info.0, name: info.1, logoURL: nil)
|
||||||
acc[kv.key] = Entry(icao: kv.key, iata: kv.value.0, name: kv.value.1, logoURL: nil)
|
icao[code] = e
|
||||||
|
iata[info.0] = e
|
||||||
}
|
}
|
||||||
byIATA = byICAO.values.reduce(into: [:]) { acc, e in
|
byICAO = icao
|
||||||
if let i = e.iata { acc[i] = e }
|
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
|
return
|
||||||
}
|
}
|
||||||
|
isLoaded = true
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
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 icaoMap: [String: Entry] = [:]
|
||||||
var iataMap: [String: Entry] = [:]
|
var iataMap: [String: Entry] = [:]
|
||||||
@@ -50,8 +69,11 @@ final class AircraftRegistry: @unchecked Sendable {
|
|||||||
if let icao { icaoMap[icao.uppercased()] = entry }
|
if let icao { icaoMap[icao.uppercased()] = entry }
|
||||||
if let iata { iataMap[iata.uppercased()] = entry }
|
if let iata { iataMap[iata.uppercased()] = entry }
|
||||||
}
|
}
|
||||||
byICAO = icaoMap
|
self?.lock.withLock {
|
||||||
byIATA = iataMap
|
self?.byICAO = icaoMap
|
||||||
|
self?.byIATA = iataMap
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up by ICAO 3-letter prefix (e.g. "DAL").
|
/// Look up by ICAO 3-letter prefix (e.g. "DAL").
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ struct LiveFlightsView: View {
|
|||||||
@State private var cachedAirlineItems: [AirlineFilterItem] = []
|
@State private var cachedAirlineItems: [AirlineFilterItem] = []
|
||||||
@State private var cachedTypeItems: [TypeFilterItem] = []
|
@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
|
/// Tracks the last bounding box we fetched against. Used to throttle
|
||||||
/// the on-pan refresh so that micro-camera-settlements (which happen
|
/// the on-pan refresh so that micro-camera-settlements (which happen
|
||||||
/// every time annotations re-render) don't fire fresh OpenSky calls.
|
/// every time annotations re-render) don't fire fresh OpenSky calls.
|
||||||
@@ -101,7 +106,15 @@ struct LiveFlightsView: View {
|
|||||||
.task(id: selectedAircraft?.icao24) {
|
.task(id: selectedAircraft?.icao24) {
|
||||||
await loadTrackForSelection()
|
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
|
.sheet(item: $activeSheet) { sheet in
|
||||||
switch sheet {
|
switch sheet {
|
||||||
case .aircraft(let ac):
|
case .aircraft(let ac):
|
||||||
@@ -145,8 +158,13 @@ struct LiveFlightsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ForEach(filteredAircraft) { ac in
|
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) {
|
Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) {
|
||||||
AircraftPin(ac: ac, isSelected: selectedAircraft?.id == ac.id)
|
AircraftPin(tint: tint, headingMinus45: rotation, isSelected: selected)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
activeSheet = .aircraft(ac)
|
activeSheet = .aircraft(ac)
|
||||||
}
|
}
|
||||||
@@ -332,23 +350,26 @@ struct LiveFlightsView: View {
|
|||||||
|
|
||||||
// MARK: - Derived
|
// 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()
|
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||||
return aircraft.filter { ac in
|
let airlines = selectedAirlineICAO
|
||||||
if hideOnGround && ac.onGround { return false }
|
let types = selectedTypeCodes
|
||||||
if !selectedAirlineICAO.isEmpty {
|
let band = selectedAltitudeBand
|
||||||
// Tightened: require an airline match. Aircraft without a
|
let hideGround = hideOnGround
|
||||||
// parseable ICAO airline prefix (N-numbers, ad-hoc callsigns)
|
|
||||||
// were slipping through as "uncategorized" — that's why
|
cachedFilteredAircraft = aircraft.filter { ac in
|
||||||
// selecting Southwest still left other carriers on screen.
|
if hideGround && ac.onGround { return false }
|
||||||
guard let code = ac.airlineICAO, selectedAirlineICAO.contains(code) else {
|
if !airlines.isEmpty {
|
||||||
return false
|
guard let code = ac.airlineICAO, airlines.contains(code) else { return false }
|
||||||
}
|
}
|
||||||
|
if !types.isEmpty {
|
||||||
|
guard let tc = ac.typeCode, types.contains(tc) else { return false }
|
||||||
}
|
}
|
||||||
if !selectedTypeCodes.isEmpty {
|
if let band {
|
||||||
guard let tc = ac.typeCode, selectedTypeCodes.contains(tc) else { return false }
|
|
||||||
}
|
|
||||||
if let band = selectedAltitudeBand {
|
|
||||||
guard let alt = ac.altitudeFeet, band.contains(alt) else { return false }
|
guard let alt = ac.altitudeFeet, band.contains(alt) else { return false }
|
||||||
}
|
}
|
||||||
if !s.isEmpty {
|
if !s.isEmpty {
|
||||||
@@ -471,7 +492,13 @@ struct LiveFlightsView: View {
|
|||||||
let results = try await openSky.states(
|
let results = try await openSky.states(
|
||||||
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
|
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
|
||||||
)
|
)
|
||||||
|
// 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
|
aircraft = results
|
||||||
|
}
|
||||||
lastFetchAt = Date()
|
lastFetchAt = Date()
|
||||||
lastFetchedBoundingBox = bb
|
lastFetchedBoundingBox = bb
|
||||||
error = nil
|
error = nil
|
||||||
@@ -519,47 +546,57 @@ struct LiveFlightsView: View {
|
|||||||
|
|
||||||
private func relativeTime(_ d: Date) -> String {
|
private func relativeTime(_ d: Date) -> String {
|
||||||
let secs = Int(Date().timeIntervalSince(d))
|
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 < 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"
|
return "\(secs / 60)m ago"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Aircraft pin
|
// MARK: - Aircraft pin
|
||||||
|
|
||||||
private struct AircraftPin: View {
|
/// Per-aircraft map pin. Kept deliberately minimal — every annotation is
|
||||||
let ac: LiveAircraft
|
/// 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
|
let isSelected: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
|
||||||
if isSelected {
|
|
||||||
Circle()
|
|
||||||
.fill(FlightTheme.accent.opacity(0.25))
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
}
|
|
||||||
Image(systemName: "airplane")
|
Image(systemName: "airplane")
|
||||||
.font(.system(size: isSelected ? 18 : 14, weight: .bold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(6)
|
.padding(6)
|
||||||
.background(
|
.background(Circle().fill(tint))
|
||||||
Circle().fill(tint)
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(.white, lineWidth: isSelected ? 2.5 : 0)
|
||||||
)
|
)
|
||||||
.rotationEffect(.degrees(Double(ac.heading ?? 0) - 45))
|
.rotationEffect(.degrees(headingMinus45))
|
||||||
// SF Symbol "airplane" points up-and-right by default;
|
.scaleEffect(isSelected ? 1.3 : 1)
|
||||||
// -45° aligns it to true north before applying the heading.
|
.animation(nil, value: tint)
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tint: Color {
|
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 }
|
if ac.onGround { return FlightTheme.textTertiary }
|
||||||
switch ac.verticalState {
|
switch ac.verticalState {
|
||||||
case .climbing: return FlightTheme.onTime
|
case .climbing: return FlightTheme.onTime
|
||||||
case .descending: return FlightTheme.delayed
|
case .descending: return FlightTheme.delayed
|
||||||
case .level: return FlightTheme.accent
|
case .level: return FlightTheme.accent
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filter chips
|
// MARK: - Filter chips
|
||||||
|
|||||||
Reference in New Issue
Block a user