Flighty:恰到好處的數據可視化

Flighty為何贏得Apple Design Award 2023:15個智慧狀態、Live Activities、機場啟發的數據視覺化。包含SwiftUI實作模式。

4 分鐘閱讀 211 字
Flighty:恰到好處的數據可視化 screenshot

Flighty:資料視覺化的典範之作

「我們希望 Flighty 運作得如此順暢,以至於一切都顯得理所當然。」

Flighty 將複雜的航班資料轉化為一目瞭然的資訊。作為 2023 年 Apple Design Award 互動設計獎得主,它堪稱資料密集型介面設計的教科書級範例——以數十年機場標誌系統為靈感,呈現出沉穩、清晰的設計語言。


為何 Flighty 值得關注

Flighty 由前 Apple 員工 Ryan Jones 在一次令人沮喪的航班延誤後創立,如今已成為 iOS 資料視覺化的業界標竿。

主要成就: - 2023 年 Apple Design Award(互動設計) - 2023 年 iPhone 年度最佳 App 入圍 - 首款完整運用 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
            )
        // ... other states
        }
    }
}

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
            // 鎖定畫面視圖
            LockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // 展開視圖
                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: {
                // 緊湊模式左側
                Text(context.attributes.gate)
                    .font(.caption)
            } compactTrailing: {
                // 緊湊模式右側
                Text(context.state.departureTime, style: .timer)
            } minimal: {
                // 最小化模式(多個活動同時存在時)
                Image(systemName: "airplane")
            }
        }
    }
}

3. 航班進度視覺化

Flighty 透過多種視覺隱喻呈現航班進度。

帶有飛機指示器的航線:

線性進度(主要)
───────────────────────────────────────────────────────────────────
SFO ════════════════●════════════════════════════════════ JFK
    [45 分鐘前起飛]    ↑ 您在這裡        [剩餘 2 小時 15 分鐘]

圓形進度(緊湊)
        ╭───────╮
       /    ●    \
      │     |     │    ← 進度弧順時針填充
      │     |     │
       \    ↓    /     剩餘 2 小時 15 分鐘
        ╰───────╯

熱力圖(終身統計)
[帶有不同粗細航線的地圖]
粗線 = 經常飛行的航線
細線 = 僅飛行過一次

SwiftUI 實作:

struct FlightProgressView: View {
    let progress: Double  // 0.0 到 1.0
    let origin: Airport
    let destination: Airport
    let remainingTime: TimeInterval

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                // 航線
                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)

                // 進度線
                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)

                // 起點標記
                Circle()
                    .fill(Color.secondary)
                    .frame(width: 8, height: 8)
                    .position(x: 40, y: geometry.size.height / 2)

                // 當前位置的飛機圖示
                Image(systemName: "airplane")
                    .foregroundStyle(Color.accentColor)
                    .position(
                        x: 40 + (geometry.size.width - 80) * progress,
                        y: geometry.size.height / 2
                    )

                // 目的地標記
                Circle()
                    .fill(Color.secondary)
                    .frame(width: 8, height: 8)
                    .position(x: geometry.size.width - 40, y: geometry.size.height / 2)

                // 標籤
                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 {
        // 根據登機門距離和一般步行速度計算
        TerminalMap.walkingTime(from: arrival.arrivalGate, to: departure.gate)
    }

    var isTight: Bool {
        connectionTime < walkingTime + 15 * 60  // 緩衝時間少於 15 分鐘
    }

    var body: some View {
        VStack(spacing: 16) {
            // 標題
            HStack {
                Text("Connection")
                    .font(.headline)
                Spacer()
                Text(connectionTime.formatted())
                    .font(.title2.bold())
                    .foregroundStyle(isTight ? .orange : .primary)
            }

            // 含路線的地圖
            TerminalMapView(
                from: arrival.arrivalGate,
                to: departure.gate,
                userLocation: userLocation
            )
            .frame(height: 200)
            .clipShape(RoundedRectangle(cornerRadius: 12))

            // 登機門資訊
            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 的 Live Activities 實作有何特別之處?

Flighty 是第一個充分利用 iOS 16 Live Activities 和靈動島的應用程式。實作包括顯示登機門和倒數計時的精簡視圖、包含完整航班詳情的展開視圖,以及無需網路連線即可運作的離線飛行進度視覺化。設計將 Live Activities 視為主要介面,而非事後添加。

Flighty 如何處理離線情境?

Flighty 在起飛前下載航班資料並儲存於本地。飛行中的進度根據預定時間和已知的飛機性能持續更新。應用程式預先計算抵達時間、轉機時間和登機門資訊。當網路連線恢復時,資料會同步並更新任何變更。這種離線優先的方法至關重要,因為航班會有數小時處於無網路狀態。

為什麼 Flighty 以機場標誌作為設計靈感?

機場航班資訊看板經過 50 多年的改良,能在繁忙環境中快速向焦慮的旅客傳達航班資訊。它們採用每班航班一行、顏色編碼狀態、僅顯示必要資訊的方式。Flighty 將這套經過驗證的視覺語言調整至行動裝置,使用機場數十年來優化的密度、清晰度和狀態指示原則。

轉機助手如何計算緊湊的轉機時間?

轉機助手比較您的抵達時間與下一班航班的起飛時間,然後根據航廈內登機門之間的距離計算步行時間。它會考量一般步行速度和航廈佈局。如果緩衝時間低於 15 分鐘,應用程式會將其標記為緊湊轉機,並顯示即時地圖,包含您目前的位置、目的地登機門和步行路線。