Flighty: 数据可视化完美指南
Flighty为何赢得Apple Design Award 2023:15个智能状态、Live Activities、机场启发的数据可视化。包含SwiftUI实现模式。
Flighty:数据可视化的典范
"我们希望 Flighty 运行得如此顺畅,以至于让人觉得理所当然。"
Flighty 将复杂的航班数据转化为一目了然的信息。作为 2023 年 Apple 设计大奖交互类获奖应用,它堪称通过沉稳、清晰的设计呈现密集数据的典范——其灵感源自数十年来机场标识系统的演进。
为什么 Flighty 值得关注
Flighty 由前 Apple 员工 Ryan Jones 在经历了一次令人沮丧的航班延误后创立,如今已成为 iOS 数据可视化的行业标杆。
主要成就: - 2023 年 Apple 设计大奖(交互类) - 2023 年 iPhone 年度应用入围 - 首个完整利用 Live Activities 功能的应用 - 15 种情境感知智能状态 - 业界领先的延误预测能力
核心要点
- 借鉴成熟的系统 - 机场出发信息板已经过 50 多年的优化;Flighty 将这套视觉语言适配到移动端
- 情境感知状态降低认知负担 - 15 种智能状态确保用户在旅途的每个阶段都能看到恰好需要的信息
- "理所当然"就是目标 - 如果应用能毫无阻碍地正常工作,你就成功了
- 打包、包装、着色你的数据 - 呈现洞察而非原始数据;通过颜色编码状态,让用户无需自行解读
- 离线优先应对不稳定的网络连接 - 航班会断网;应用必须在无网络环境下正常工作,尤其是飞行途中
核心设计理念
以机场标识为设计语言
Flighty 的设计建立在一个现实世界的类比之上:机场出发信息板。
AIRPORT DEPARTURE BOARD (50+ years of refinement)
───────────────────────────────────────────────────────────────────
FLIGHT DESTINATION GATE TIME STATUS
UA 1234 San Francisco B22 14:30 ON TIME
DL 567 New York JFK C15 15:00 DELAYED
AA 890 Chicago O'Hare A08 15:45 BOARDING
One line per flight. Essential info only. Decades of optimization.
FLIGHTY'S TRANSLATION
───────────────────────────────────────────────────────────────────
[Flight card with same information density]
[Color-coded status]
[Time-appropriate details]
核心洞察:"机场信息板每个航班只占一行,这是一个很好的指导原则——他们用了 50 年时间来确定什么才是重要的。"
旅行情境感知
Flighty 深知旅行充满压力。设计必须降低认知负担,而非增加。
设计原则: 1. 最重要的信息放在首屏 2. 渐进式披露详细信息 3. 颜色编码状态(绿色 = 正常,黄色 = 警告,红色 = 问题) 4. 数据经过"打包、包装和着色",用户无需自行解读
设计模式库
1. 15 种智能状态
Flighty 的标志性创新:15 种情境感知状态,在恰当的时机展示恰当的信息。
状态时间线:
24 HOURS BEFORE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO │
│ Tomorrow at 2:30 PM │
│ Confirmation: ABC123 │
│ Head to Terminal 2 │
└─────────────────────────────────────────┘
3 HOURS BEFORE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO │
│ Gate B22 • Boards 2:00 PM │
│ Leave for airport by 12:15 PM │
│ [View full timeline →] │
└─────────────────────────────────────────┘
AT GATE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO [ON TIME] │
│ Gate B22 • Boarding in 12 min │
│ Seat 14A • Window │
│ [Your plane is here ✓] │
└─────────────────────────────────────────┘
WALKING TO PLANE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO [BOARDING] │
│ Your seat: 14A (Window) │
│ ▓▓▓▓▓▓▓▓░░ 80% boarded │
└─────────────────────────────────────────┘
IN FLIGHT (offline)
┌─────────────────────────────────────────┐
│ SFO ──────●───────────── JFK │
│ │ │
│ 2h 15m remaining │
│ Arrive ~5:45 PM local │
└─────────────────────────────────────────┘
LANDED
┌─────────────────────────────────────────┐
│ ✓ Arrived at JFK │
│ Your gate: C15 → Connection gate: D22 │
│ 8 min walk • Terminal map [→] │
└─────────────────────────────────────────┘
实现概念:
enum FlightState: CaseIterable {
case farOut // > 24 hours
case dayBefore // 24 hours
case headToAirport // ~3 hours
case atAirport // At terminal
case atGate // Gate area
case boarding // Boarding started
case onBoard // Seated
case taxiing // Moving to runway
case inFlight // Airborne
case descending // Approaching
case landed // Touched down
case atGateDest // Arrived at gate
case connection // Connecting flight
case completed // Trip done
case delayed // Any delay state
}
struct SmartStateEngine {
func currentState(for flight: Flight, context: TravelContext) -> FlightState {
let now = Date()
let departure = flight.scheduledDeparture
// Contextual factors
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: "Gate \(flight.gate) • Boards \(flight.boardingTime.formatted())",
secondary: "Leave for airport by \(flight.recommendedDeparture.formatted())",
action: "View full timeline"
)
case .inFlight:
return StateContent(
headline: flight.routeVisualization,
primary: "\(flight.remainingTime.formatted()) remaining",
secondary: "Arrive ~\(flight.estimatedArrival.formatted())",
action: nil
)
// ... 其他状态
}
}
}
2. 实时活动与灵动岛
Flighty 是首个充分利用 iOS 16 实时活动功能的应用,为这一特性树立了行业标杆。
灵动岛状态:
COMPACT(紧凑模式)
╭──────────────────────────────────╮
│ [^] B22 │ * │ 2:30 -> 1h 45m │
╰──────────────────────────────────╯
登机口 状态 时间/时长
EXPANDED(长按展开)
╭────────────────────────────────────────╮
│ UA 1234 飞往旧金山 │
│ ─────────────────────────────────── │
│ 登机口 B22 登机时间 2:00 PM │
│ 座位 14A 准点 ✓ │
│ │
│ [打开 Flighty] [分享预计到达时间] │
╰────────────────────────────────────────╯
IN-FLIGHT(飞行中,支持离线)
╭──────────────────────────────────╮
│ SFO ●════════○──── JFK │
│ 2小时15分钟 │
╰──────────────────────────────────╯
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 到 1.0
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
// Lock Screen view
LockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
// Expanded view
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading) {
Text(context.attributes.flightNumber)
.font(.headline)
Text("to \(context.attributes.destination.city)")
.font(.caption)
}
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing) {
Text("Gate \(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("Boards \(context.state.boardingTime.formatted())")
}
}
} compactLeading: {
// Compact left side
Text(context.attributes.gate)
.font(.caption)
} compactTrailing: {
// Compact right side
Text(context.state.departureTime, style: .timer)
} minimal: {
// Minimal (when multiple activities)
Image(systemName: "airplane")
}
}
}
}
3. 航班进度可视化
Flighty 通过多种视觉隐喻展示航班进度。
带飞机指示器的航线:
线性进度(主要)
───────────────────────────────────────────────────────────────────
SFO ════════════════●════════════════════════════════════ JFK
[45分钟前起飞] ↑ 您当前位置 [剩余 2小时15分钟]
圆形进度(紧凑型)
╭───────╮
/ ● \
│ | │ ← 进度弧顺时针填充
│ | │
\ ↓ / 剩余 2小时15分钟
╰───────╯
热力图(终身统计)
[具有不同粗细航线的地图]
粗线 = 频繁飞行的航线
细线 = 仅飞行过一次
SwiftUI 实现:
struct FlightProgressView: View {
let progress: Double // 0.0 to 1.0
let origin: Airport
let destination: Airport
let remainingTime: TimeInterval
var body: some View {
GeometryReader { geometry in
ZStack {
// Route line
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)
// Progress line
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)
// Origin marker
Circle()
.fill(Color.secondary)
.frame(width: 8, height: 8)
.position(x: 40, y: geometry.size.height / 2)
// Plane icon at current position
Image(systemName: "airplane")
.foregroundStyle(Color.accentColor)
.position(
x: 40 + (geometry.size.width - 80) * progress,
y: geometry.size.height / 2
)
// Destination marker
Circle()
.fill(Color.secondary)
.frame(width: 8, height: 8)
.position(x: geometry.size.width - 40, y: geometry.size.height / 2)
// Labels
HStack {
Text(origin.code)
.font(.caption.bold())
Spacer()
Text(destination.code)
.font(.caption.bold())
}
.padding(.horizontal, 20)
}
}
.frame(height: 60)
}
}
4. 中转助手
当您降落后需要转机时,Flighty 会同时显示两个登机口并标注您的实时位置。
中转视图
┌─────────────────────────────────────────┐
│ 中转时间:45 分钟 │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ │ │
│ │ [航站楼地图] │ │
│ │ │ │
│ │ [*] 您 -> -> -> -> -> [>] D22 │ │
│ │ C15 步行 8 分钟 │ │
│ │ │ │
│ └─────────────────────────────────────┘ │
│ │
│ [!] 中转时间紧张 - 请立即前往 │
└─────────────────────────────────────────┘
实现代码:
struct ConnectionAssistantView: View {
let arrival: Flight
let departure: Flight
let userLocation: CLLocationCoordinate2D?
var connectionTime: TimeInterval {
departure.scheduledDeparture.timeIntervalSince(arrival.estimatedArrival)
}
var walkingTime: TimeInterval {
// Calculate based on gate distance and typical walking speed
TerminalMap.walkingTime(from: arrival.arrivalGate, to: departure.gate)
}
var isTight: Bool {
connectionTime < walkingTime + 15 * 60 // Less than 15 min buffer
}
var body: some View {
VStack(spacing: 16) {
// Header
HStack {
Text("Connection")
.font(.headline)
Spacer()
Text(connectionTime.formatted())
.font(.title2.bold())
.foregroundStyle(isTight ? .orange : .primary)
}
// Map with route
TerminalMapView(
from: arrival.arrivalGate,
to: departure.gate,
userLocation: userLocation
)
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
// Gate info
HStack {
GateLabel(gate: arrival.arrivalGate, label: "Arrival")
Spacer()
Text("\(Int(walkingTime / 60)) min walk")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
GateLabel(gate: departure.gate, label: "Departure")
}
// Warning if tight
if isTight {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Tight connection - start moving")
.font(.callout)
}
.padding()
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding()
}
}
5. 护照与终身统计
Flighty 将旅行历史转化为精美的可分享可视化内容。
PASSPORT PAGE
┌─────────────────────────────────────────┐
│ [^] FLIGHTY PASSPORT │
│ │
│ [Custom artwork with travel themes] │
│ │
│ ─────────────────────────────────── │
│ 2024 │
│ │
│ 47 flights │
│ 89,234 miles │
│ 23 airports │
│ 12 countries │
│ │
│ Most visited: SFO (23 times) │
│ Longest flight: SFO → SIN (8,447 mi) │
│ ─────────────────────────────────── │
│ │
│ [Share] [View Details] │
└─────────────────────────────────────────┘
视觉设计系统
色彩调色板
extension Color {
// Status colors (airport-inspired)
static let flightOnTime = Color(hex: "#10B981") // Green
static let flightDelayed = Color(hex: "#F59E0B") // Amber
static let flightCancelled = Color(hex: "#EF4444") // Red
static let flightBoarding = Color(hex: "#3B82F6") // Blue
// UI colors
static let flightPrimary = Color(hex: "#1F2937")
static let flightSecondary = Color(hex: "#6B7280")
static let flightBackground = Color(hex: "#F9FAFB")
static let flightCard = Color(hex: "#FFFFFF")
// Accent
static let flightAccent = Color(hex: "#6366F1") // Indigo
}
字体排印
struct FlightyTypography {
// Flight numbers and gates (need to stand out)
static let flightNumber = Font.system(size: 20, weight: .bold, design: .monospaced)
static let gate = Font.system(size: 24, weight: .bold, design: .rounded)
// Time displays
static let time = Font.system(size: 18, weight: .medium, design: .monospaced)
static let countdown = Font.system(size: 32, weight: .bold, design: .monospaced)
// Status and labels
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)
}
动画设计理念
有目的的动效
Flighty 的动画服务于功能,而非装饰。
// Status change animation
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()) // Smooth number changes
.animation(.spring(response: 0.3), value: status)
}
}
// Progress bar animation
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) // 缓慢、持续
}
}
}
对我们工作的启示
1. 现实世界的类比指导设计
机场标识系统经过 50 多年的优化完善。借鉴已被验证的系统设计。
2. 场景感知状态降低认知负荷
15 种智能状态意味着用户永远不会看到无关信息。
3. 平淡无奇、显而易见才是目标
如果应用"就是能用",那你就成功了。摩擦即是失败。
4. 打包、封装、着色你的数据
不要展示原始数据。呈现洞见。用颜色标识状态。
5. 移动端优先离线
航班会断网。应用必须在无网络连接时也能正常工作。
常见问题
Flighty 的 15 种智能状态是如何运作的?
Flighty 监控你的航班状态、位置和时间,自动显示最相关的信息。状态范围从"较远"(起飞前 24 小时以上)到"前往机场"、"在登机口"、"登机中"、"飞行中"和"已降落"。每种状态展示不同的详情:早期显示确认号,相关时刻显示登机口信息,飞行途中显示剩余飞行时间,降落后显示转机指引。
Flighty 的实时活动实现有何特别之处?
Flighty 是第一个充分利用 iOS 16 实时活动和灵动岛功能的应用。其实现包括显示登机口和倒计时的紧凑视图、包含完整航班详情的展开视图,以及无需网络连接即可工作的离线飞行进度可视化。该设计将实时活动视为主要界面,而非事后添加的功能。
Flighty 如何处理离线场景?
Flighty 在起飞前下载航班数据并存储在本地。飞行进度根据预定时间和已知飞机性能持续更新。应用预先计算到达时间估算、转机时间和登机口信息。当恢复网络连接时,数据会同步并更新任何变化。这种离线优先的方式至关重要,因为航班会有数小时处于无网络状态。
为什么 Flighty 以机场标识为设计灵感?
机场出发信息板经过 50 多年的改进,能够在繁忙环境中快速向焦虑的旅客传达航班信息。它们采用每航班一行、颜色编码状态和仅显示关键信息的方式。Flighty 将这套经过验证的视觉语言适配到移动端,运用机场数十年来优化的密度、清晰度和状态指示原则。
转机助手如何计算紧张的转机?
转机助手比较你的到达时间和下一班航班的起飞时间,然后根据航站楼内的登机口距离计算步行时间。它会考虑典型步行速度和航站楼布局。如果缓冲时间低于 15 分钟,应用会将其标记为紧张转机,并显示包含你当前位置、目的地登机口和步行路线的实时地图。