Flighty: 数据可视化完美指南

Flighty为何赢得Apple Design Award 2023:15个智能状态、Live Activities、机场启发的数据可视化。包含SwiftUI实现模式。

4 分钟阅读 198 字
Flighty: 数据可视化完美指南 screenshot

Flighty:数据可视化的典范

"我们希望 Flighty 运行得如此顺畅,以至于让人觉得理所当然。"

Flighty 将复杂的航班数据转化为一目了然的信息。作为 2023 年 Apple 设计大奖交互类获奖应用,它堪称通过沉稳、清晰的设计呈现密集数据的典范——其灵感源自数十年来机场标识系统的演进。


为什么 Flighty 值得关注

Flighty 由前 Apple 员工 Ryan Jones 在经历了一次令人沮丧的航班延误后创立,如今已成为 iOS 数据可视化的行业标杆。

主要成就: - 2023 年 Apple 设计大奖(交互类) - 2023 年 iPhone 年度应用入围 - 首个完整利用 Live Activities 功能的应用 - 15 种情境感知智能状态 - 业界领先的延误预测能力


核心要点

  1. 借鉴成熟的系统 - 机场出发信息板已经过 50 多年的优化;Flighty 将这套视觉语言适配到移动端
  2. 情境感知状态降低认知负担 - 15 种智能状态确保用户在旅途的每个阶段都能看到恰好需要的信息
  3. "理所当然"就是目标 - 如果应用能毫无阻碍地正常工作,你就成功了
  4. 打包、包装、着色你的数据 - 呈现洞察而非原始数据;通过颜色编码状态,让用户无需自行解读
  5. 离线优先应对不稳定的网络连接 - 航班会断网;应用必须在无网络环境下正常工作,尤其是飞行途中

核心设计理念

以机场标识为设计语言

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 分钟,应用会将其标记为紧张转机,并显示包含你当前位置、目的地登机口和步行路线的实时地图。