design/flighty
Flighty: Wizualizacja danych zrobiona jak należy
“Chcemy, żeby Flighty działało tak dobrze, że wydawało się niemal nudnie oczywiste.”
Flighty przekształca złożone dane lotnicze w informacje, które można ogarnąć jednym rzutem oka. Laureat Apple Design Award 2023 w kategorii Interakcja, to mistrzowska lekcja prezentowania gęstych danych poprzez spokojny, przejrzysty design inspirowany dziesięcioleciami oznakowania lotniskowego.
Dlaczego Flighty ma znaczenie
Założone przez byłego pracownika Apple, Ryana Jonesa, po frustrującym opóźnieniu lotu, Flighty stało się złotym standardem wizualizacji danych na iOS.
Kluczowe osiągnięcia: - Apple Design Award 2023 (Interakcja) - Finalista iPhone App of the Year 2023 - Pierwsza aplikacja w pełni wykorzystująca Live Activities - 15 kontekstowych stanów inteligentnych - Wiodące w branży przewidywanie opóźnień
Kluczowe wnioski
- Czerp ze sprawdzonych systemów - Lotniskowe tablice odlotów mają 50+ lat optymalizacji; Flighty adaptuje ten język wizualny na urządzenia mobilne
- Stany kontekstowe redukują obciążenie poznawcze - 15 inteligentnych stanów zapewnia, że użytkownicy widzą dokładnie to, czego potrzebują na każdym etapie podróży
- “Nudnie oczywiste” to cel - Jeśli aplikacja po prostu działa bez tarcia, odniosłeś sukces
- Pakuj, owijaj i koloruj dane - Prezentuj wnioski, nie surowe dane; koduj kolorami statusy, żeby użytkownicy nie musieli interpretować
- Offline-first dla niestabilnej łączności - Loty tracą połączenie; aplikacja musi działać bez internetu, szczególnie podczas lotu
Podstawowa filozofia designu
Oznakowanie lotniskowe jako język designu
Design Flighty jest zbudowany na analogii ze świata rzeczywistego: lotniskowe tablice odlotów.
LOTNISKOWA TABLICA ODLOTÓW (50+ lat udoskonaleń)
───────────────────────────────────────────────────────────────────
LOT CEL BRAMKA CZAS STATUS
UA 1234 San Francisco B22 14:30 PLANOWO
DL 567 New York JFK C15 15:00 OPÓŹNIONY
AA 890 Chicago O'Hare A08 15:45 BOARDING
Jeden wiersz na lot. Tylko niezbędne informacje. Dekady optymalizacji.
TŁUMACZENIE FLIGHTY
───────────────────────────────────────────────────────────────────
[Karta lotu z tą samą gęstością informacji]
[Status kodowany kolorami]
[Szczegóły odpowiednie do czasu]
Kluczowy wniosek: “Te lotniskowe tablice mają jeden wiersz na lot i to jest dobry drogowskaz—miały 50 lat na ustalenie, co jest ważne.”
Świadomość kontekstu podróży
Flighty rozumie, że podróżowanie jest stresujące. Design musi redukować obciążenie poznawcze, nie zwiększać go.
Zasady designu: 1. Najważniejsze informacje widoczne bez przewijania 2. Progresywne ujawnianie szczegółów 3. Status kodowany kolorami (zielony = dobrze, żółty = ostrzeżenie, czerwony = problem) 4. Dane są “pakowane, owijane i kolorowane”, żeby użytkownicy nie musieli interpretować
Biblioteka wzorców
1. 15 inteligentnych stanów
Charakterystyczna innowacja Flighty: 15 kontekstowych stanów, które pokazują dokładnie to, czego potrzebujesz, dokładnie wtedy, gdy tego potrzebujesz.
Oś czasu stanów:
24 GODZINY PRZED
┌─────────────────────────────────────────┐
│ UA 1234 → SFO │
│ Jutro o 14:30 │
│ Potwierdzenie: ABC123 │
│ Kieruj się do Terminala 2 │
└─────────────────────────────────────────┘
3 GODZINY PRZED
┌─────────────────────────────────────────┐
│ UA 1234 → SFO │
│ Bramka B22 • Boarding 14:00 │
│ Wyjedź na lotnisko do 12:15 │
│ [Zobacz pełną oś czasu →] │
└─────────────────────────────────────────┘
PRZY BRAMCE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO [PLANOWO] │
│ Bramka B22 • Boarding za 12 min │
│ Miejsce 14A • Okno │
│ [Twój samolot jest tutaj ✓] │
└─────────────────────────────────────────┘
DROGA DO SAMOLOTU
┌─────────────────────────────────────────┐
│ UA 1234 → SFO [BOARDING] │
│ Twoje miejsce: 14A (Okno) │
│ ▓▓▓▓▓▓▓▓░░ 80% wsiadło │
└─────────────────────────────────────────┘
W LOCIE (offline)
┌─────────────────────────────────────────┐
│ SFO ──────●───────────── JFK │
│ │ │
│ Pozostało 2h 15m │
│ Przylot ~17:45 czasu lokalnego │
└─────────────────────────────────────────┘
WYLĄDOWANO
┌─────────────────────────────────────────┐
│ ✓ Przylot do JFK │
│ Twoja bramka: C15 → Bramka przesiadki: D22 │
│ 8 min spaceru • Mapa terminala [→] │
└─────────────────────────────────────────┘
Koncepcja implementacji:
enum FlightState: CaseIterable {
case farOut // > 24 godziny
case dayBefore // 24 godziny
case headToAirport // ~3 godziny
case atAirport // W terminalu
case atGate // Strefa bramki
case boarding // Rozpoczęto boarding
case onBoard // Na miejscu
case taxiing // Kołowanie na pas
case inFlight // W powietrzu
case descending // Podejście
case landed // Wylądowano
case atGateDest // Przy bramce docelowej
case connection // Lot przesiadkowy
case completed // Podróż zakończona
case delayed // Dowolny stan opóźnienia
}
struct SmartStateEngine {
func currentState(for flight: Flight, context: TravelContext) -> FlightState {
let now = Date()
let departure = flight.scheduledDeparture
// Czynniki kontekstowe
let isAtAirport = context.currentLocation.isNear(flight.departureAirport)
let isBoarding = flight.boardingStatus == .active
let isAirborne = flight.status == .inFlight
switch true {
case isAirborne:
return flight.altitude > 10000 ? .inFlight : .taxiing
case isBoarding && isAtAirport:
return .boarding
case isAtAirport && departure.timeIntervalSinceNow < 3600:
return .atGate
case isAtAirport:
return .atAirport
case departure.timeIntervalSinceNow < 3 * 3600:
return .headToAirport
case departure.timeIntervalSinceNow < 24 * 3600:
return .dayBefore
default:
return .farOut
}
}
func displayContent(for state: FlightState, flight: Flight) -> StateContent {
switch state {
case .headToAirport:
return StateContent(
headline: "\(flight.number) → \(flight.destination.code)",
primary: "Bramka \(flight.gate) • Boarding \(flight.boardingTime.formatted())",
secondary: "Wyjedź na lotnisko do \(flight.recommendedDeparture.formatted())",
action: "Zobacz pełną oś czasu"
)
case .inFlight:
return StateContent(
headline: flight.routeVisualization,
primary: "Pozostało \(flight.remainingTime.formatted())",
secondary: "Przylot ~\(flight.estimatedArrival.formatted())",
action: nil
)
// ... inne stany
}
}
}
2. Live Activities i Dynamic Island
Flighty było pierwszą aplikacją, która w pełni wykorzystała Live Activities z iOS 16, ustanawiając standard dla tej funkcji.
Stany Dynamic Island:
KOMPAKTOWY (Minimalny)
╭──────────────────────────────────╮
│ [^] B22 │ * │ 14:30 -> 1h 45m │
╰──────────────────────────────────╯
Bramka Status Czas/Trwanie
ROZWINIĘTY (Długie naciśnięcie)
╭────────────────────────────────────────╮
│ UA 1234 do San Francisco │
│ ─────────────────────────────────── │
│ Bramka B22 Boarding 14:00 │
│ Miejsce 14A Planowo ✓ │
│ │
│ [Otwórz Flighty] [Udostępnij ETA] │
╰────────────────────────────────────────╯
W LOCIE (Działa offline)
╭──────────────────────────────────╮
│ SFO ●════════○──── JFK │
│ 2h 15m │
╰──────────────────────────────────╯
Wzorzec implementacji SwiftUI:
import ActivityKit
import WidgetKit
struct FlightActivityAttributes: ActivityAttributes {
public typealias ContentState = FlightStatus
let flightNumber: String
let origin: Airport
let destination: Airport
let gate: String
let seat: String
struct FlightStatus: Codable, Hashable {
let departureTime: Date
let arrivalTime: Date
let status: Status
let boardingStatus: BoardingStatus
let progress: Double // 0.0 do 1.0 dla lotu
let phase: FlightPhase
enum Status: String, Codable { case onTime, delayed, cancelled }
enum BoardingStatus: String, Codable { case notStarted, boarding, closed }
enum FlightPhase: String, Codable { case preBoarding, boarding, taxiing, inFlight, landed }
}
}
struct FlightLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: FlightActivityAttributes.self) { context in
// Widok ekranu blokady
LockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
// Widok rozwinięty
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading) {
Text(context.attributes.flightNumber)
.font(.headline)
Text("do \(context.attributes.destination.city)")
.font(.caption)
}
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing) {
Text("Bramka \(context.attributes.gate)")
Text(context.state.status.displayText)
.foregroundStyle(context.state.status.color)
}
}
DynamicIslandExpandedRegion(.bottom) {
if context.state.phase == .inFlight {
FlightProgressBar(progress: context.state.progress)
} else {
Text("Boarding \(context.state.boardingTime.formatted())")
}
}
} compactLeading: {
// Lewa strona kompaktowa
Text(context.attributes.gate)
.font(.caption)
} compactTrailing: {
// Prawa strona kompaktowa
Text(context.state.departureTime, style: .timer)
} minimal: {
// Minimalny (gdy wiele aktywności)
Image(systemName: "airplane")
}
}
}
}
3. Wizualizacja postępu lotu
Flighty pokazuje postęp lotu poprzez różne metafory wizualne.
Linia trasy ze wskaźnikiem samolotu:
POSTĘP LINIOWY (Główny)
───────────────────────────────────────────────────────────────────
SFO ════════════════●════════════════════════════════════ JFK
[Odlot 45m temu] ↑ Jesteś tutaj [Pozostało 2h 15m]
POSTĘP KOŁOWY (Kompaktowy)
╭───────╮
/ ● \
│ | │ ← Łuk postępu wypełnia się zgodnie z ruchem wskazówek zegara
│ | │
\ ↓ / Pozostało 2h 15m
╰───────╯
MAPA CIEPLNA (Statystyki całkowite)
[Mapa z liniami tras o różnej grubości]
Gruba linia = często podróżowana trasa
Cienka linia = podróżowano raz
Implementacja SwiftUI:
struct FlightProgressView: View {
let progress: Double // 0.0 do 1.0
let origin: Airport
let destination: Airport
let remainingTime: TimeInterval
var body: some View {
GeometryReader { geometry in
ZStack {
// Linia trasy
Path { path in
let startX: CGFloat = 40
let endX = geometry.size.width - 40
let y = geometry.size.height / 2
path.move(to: CGPoint(x: startX, y: y))
path.addLine(to: CGPoint(x: endX, y: y))
}
.stroke(Color.secondary.opacity(0.3), lineWidth: 3)
// Linia postępu
Path { path in
let startX: CGFloat = 40
let endX = geometry.size.width - 40
let progressX = startX + (endX - startX) * progress
let y = geometry.size.height / 2
path.move(to: CGPoint(x: startX, y: y))
path.addLine(to: CGPoint(x: progressX, y: y))
}
.stroke(Color.accentColor, lineWidth: 3)
// Znacznik miejsca startu
Circle()
.fill(Color.secondary)
.frame(width: 8, height: 8)
.position(x: 40, y: geometry.size.height / 2)
// Ikona samolotu w aktualnej pozycji
Image(systemName: "airplane")
.foregroundStyle(Color.accentColor)
.position(
x: 40 + (geometry.size.width - 80) * progress,
y: geometry.size.height / 2
)
// Znacznik miejsca docelowego
Circle()
.fill(Color.secondary)
.frame(width: 8, height: 8)
.position(x: geometry.size.width - 40, y: geometry.size.height / 2)
// Etykiety
HStack {
Text(origin.code)
.font(.caption.bold())
Spacer()
Text(destination.code)
.font(.caption.bold())
}
.padding(.horizontal, 20)
}
}
.frame(height: 60)
}
}
4. Asystent przesiadek
Po wylądowaniu z lotem przesiadkowym, Flighty pokazuje obie bramki wraz z Twoją lokalizacją na żywo.
WIDOK PRZESIADKI
┌─────────────────────────────────────────┐
│ Przesiadka: 45 minut │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ │ │
│ │ [Mapa terminala] │ │
│ │ │ │
│ │ [*] Ty -> -> -> -> -> [>] D22 │ │
│ │ C15 8 min spaceru │ │
│ │ │ │
│ └─────────────────────────────────────┘ │
│ │
│ [!] Napięta przesiadka - ruszaj się │
└─────────────────────────────────────────┘
Implementacja:
struct ConnectionAssistantView: View {
let arrival: Flight
let departure: Flight
let userLocation: CLLocationCoordinate2D?
var connectionTime: TimeInterval {
departure.scheduledDeparture.timeIntervalSince(arrival.estimatedArrival)
}
var walkingTime: TimeInterval {
// Oblicz na podstawie odległości bramek i typowej prędkości chodu
TerminalMap.walkingTime(from: arrival.arrivalGate, to: departure.gate)
}
var isTight: Bool {
connectionTime < walkingTime + 15 * 60 // Mniej niż 15 min zapasu
}
var body: some View {
VStack(spacing: 16) {
// Nagłówek
HStack {
Text("Przesiadka")
.font(.headline)
Spacer()
Text(connectionTime.formatted())
.font(.title2.bold())
.foregroundStyle(isTight ? .orange : .primary)
}
// Mapa z trasą
TerminalMapView(
from: arrival.arrivalGate,
to: departure.gate,
userLocation: userLocation
)
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
// Informacje o bramkach
HStack {
GateLabel(gate: arrival.arrivalGate, label: "Przylot")
Spacer()
Text("\(Int(walkingTime / 60)) min spaceru")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
GateLabel(gate: departure.gate, label: "Odlot")
}
// Ostrzeżenie jeśli napięta przesiadka
if isTight {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Napięta przesiadka - ruszaj się")
.font(.callout)
}
.padding()
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding()
}
}
5. Paszport i statystyki całkowite
Flighty przekształca historię podróży w piękne, nadające się do udostępniania wizualizacje.
STRONA PASZPORTU
┌─────────────────────────────────────────┐
│ [^] PASZPORT FLIGHTY │
│ │
│ [Niestandardowa grafika z motywami │
│ podróżniczymi] │
│ │
│ ─────────────────────────────────── │
│ 2024 │
│ │
│ 47 lotów │
│ 89 234 mil │
│ 23 lotniska │
│ 12 krajów │
│ │
│ Najczęściej odwiedzane: SFO (23 razy) │
│ Najdłuższy lot: SFO → SIN (8 447 mil) │
│ ─────────────────────────────────── │
│ │
│ [Udostępnij] [Zobacz szczegóły] │
└─────────────────────────────────────────┘
System designu wizualnego
Paleta kolorów
extension Color {
// Kolory statusów (inspirowane lotniskiem)
static let flightOnTime = Color(hex: "#10B981") // Zielony
static let flightDelayed = Color(hex: "#F59E0B") // Bursztynowy
static let flightCancelled = Color(hex: "#EF4444") // Czerwony
static let flightBoarding = Color(hex: "#3B82F6") // Niebieski
// Kolory UI
static let flightPrimary = Color(hex: "#1F2937")
static let flightSecondary = Color(hex: "#6B7280")
static let flightBackground = Color(hex: "#F9FAFB")
static let flightCard = Color(hex: "#FFFFFF")
// Akcent
static let flightAccent = Color(hex: "#6366F1") // Indygo
}
Typografia
struct FlightyTypography {
// Numery lotów i bramki (muszą się wyróżniać)
static let flightNumber = Font.system(size: 20, weight: .bold, design: .monospaced)
static let gate = Font.system(size: 24, weight: .bold, design: .rounded)
// Wyświetlanie czasu
static let time = Font.system(size: 18, weight: .medium, design: .monospaced)
static let countdown = Font.system(size: 32, weight: .bold, design: .monospaced)
// Status i etykiety
static let status = Font.system(size: 14, weight: .semibold)
static let label = Font.system(size: 12, weight: .medium)
static let body = Font.system(size: 16, weight: .regular)
}
Filozofia animacji
Celowy ruch
Animacje Flighty służą funkcji, nie dekoracji.
// Animacja zmiany statusu
struct StatusBadge: View {
let status: FlightStatus
var body: some View {
Text(status.displayText)
.font(.caption.bold())
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(status.color)
.foregroundStyle(.white)
.clipShape(Capsule())
.contentTransition(.numericText()) // Płynne zmiany liczb
.animation(.spring(response: 0.3), value: status)
}
}
// Animacja paska postępu
struct AnimatedProgress: View {
let progress: Double
var body: some View {
GeometryReader { geo in
Rectangle()
.fill(Color.accentColor)
.frame(width: geo.size.width * progress)
.animation(.linear(duration: 60), value: progress) // Wolna, ciągła
}
}
}
Lekcje dla naszej pracy
1. Analogie ze świata rzeczywistego kierują designem
Oznakowanie lotniskowe ma 50+ lat optymalizacji. Czerp ze sprawdzonych systemów.
2. Stany kontekstowe redukują obciążenie poznawcze
15 inteligentnych stanów oznacza, że użytkownicy nigdy nie widzą nieistotnych informacji.
3. Nudnie oczywiste to cel
Jeśli aplikacja “po prostu działa”, odniosłeś sukces. Tarcie to porażka.
4. Pakuj, owijaj, koloruj dane
Nie pokazuj surowych danych. Prezentuj wnioski. Koduj kolorami statusy.
5. Offline-first dla urządzeń mobilnych
Loty tracą połączenie. Aplikacja musi działać bez internetu.
Często zadawane pytania
Jak działają 15 inteligentnych stanów Flighty?
Flighty monitoruje status Twojego lotu, lokalizację i czas, aby automatycznie wyświetlać najbardziej istotne informacje. Stany obejmują zakres od “daleko” (24+ godziny przed) przez “jedź na lotnisko”, “przy bramce”, “boarding”, “w locie” i “wylądowano”. Każdy stan wyświetla inne szczegóły: numery potwierdzenia wcześnie, informacje o bramce gdy są istotne, pozostały czas lotu podczas podróży i wskazówki dotyczące przesiadki po lądowaniu.
Co sprawia, że implementacja Live Activities w Flighty jest wyjątkowa?
Flighty było pierwszą aplikacją, która w pełni wykorzystała Live Activities i Dynamic Island z iOS 16. Implementacja obejmuje widoki kompaktowe pokazujące bramkę i odliczanie, widoki rozwinięte z pełnymi szczegółami lotu oraz wizualizację postępu lotu działającą offline bez łączności. Design traktuje Live Activities jako główny interfejs, nie jako dodatek.
Jak Flighty radzi sobie ze scenariuszami offline?
Flighty pobiera dane lotu i przechowuje je lokalnie przed odlotem. Postęp w locie kontynuuje się na podstawie zaplanowanego czasu i znanych parametrów samolotu. Aplikacja wstępnie oblicza szacowane czasy przylotu, czasy przesiadek i informacje o bramkach. Gdy łączność powraca, dane są synchronizowane i aktualizowane o wszelkie zmiany. To podejście offline-first jest niezbędne, ponieważ loty spędzają godziny bez internetu.
Dlaczego Flighty używa oznakowania lotniskowego jako inspiracji designu?
Lotniskowe tablice odlotów były udoskonalane przez ponad 50 lat, aby szybko komunikować informacje o lotach zestresowanym podróżnym w zatłoczonych miejscach. Używają jednego wiersza na lot, statusu kodowanego kolorami i tylko niezbędnych informacji. Flighty adaptuje ten sprawdzony język wizualny na urządzenia mobilne, wykorzystując te same zasady gęstości, przejrzystości i wskazywania statusu, które lotniska optymalizowały przez dekady.
Jak asystent przesiadek oblicza napięte przesiadki?
Asystent przesiadek porównuje Twój czas przylotu z następnym odlotem, następnie oblicza czas dojścia pieszo na podstawie odległości bramek w terminalu. Uwzględnia typowe prędkości chodu i układy terminali. Jeśli zapas spada poniżej 15 minut, aplikacja oznacza to jako napiętą przesiadkę i pokazuje mapę na żywo z Twoją aktualną lokalizacją, bramką docelową i trasą pieszą.