← 所有文章

tvOS Focus Engine:Siri Remote 的 SwiftUI 模式

Apple TV 是 Apple 唯一沒有觸控介面的平台。使用者透過 Siri Remote 上的方向滑動與按鈕操作來導航,每一次互動都會經過焦點引擎:這是一套根據幾何位置、階層結構與開發者所宣告的焦點結構,決定哪個元件接下來會獲得焦點的系統1。SwiftUI 在 tvOS 上提供了一套專注(請容許這個雙關語)於與該引擎協作的詞彙:.focusable@FocusState.focused.focusSection.prefersDefaultFocus.focusEffectDisabled。採用此詞彙的應用程式會給人原生感受;與之對抗的應用程式則會讓使用者產生「遙控器拒絕導航到預期位置」的體驗。

本文走過焦點引擎的API層,以可實際運用的模式為主軸。整體框架是「引擎假設了什麼,以及 SwiftUI 如何讓你協作」,因為在 iOS 上以點擊與捲動運作良好的焦點設計,在 tvOS 上往往會失敗,而本系列的 Apple 平台矩陣一文已論述過,tvOS 唯有具備焦點感知的介面,才配得上自己的位置。

TL;DR

  • 焦點引擎依幾何位置解析焦點:在滑動方向上挑選最近的可聚焦視圖1。應用程式透過宣告可聚焦視圖、焦點區段與預設焦點目標來協作。
  • @FocusState(搭配 .focused(_:equals:))是 SwiftUI 中用於程式化控制焦點的基本元件。同一個 property wrapper 在 iOS、macOS、watchOS 與 tvOS 上都可使用,但 tvOS 才是它真正發揮作用的舞台2
  • .focusSection() 將多個可聚焦視圖組成單一焦點目標,以便在區段之間導航,並讓引擎在區段內自行挑選3。適用於按鈕列、卡片網格、側邊欄區段。
  • .prefersDefaultFocus(_:in:) 宣告當使用者進入某個情境(畫面、彈出視窗、分頁)時,哪個視圖應接收焦點。搭配 @Namespace 來限定預設範圍4
  • 系統焦點效果(在聚焦視圖周圍展開的高亮)會自動套用。請僅在實作自訂焦點視覺時,才以 .focusEffectDisabled() 停用;否則,平台原生效果就是正確選擇。

焦點引擎如何決策

焦點引擎處理 Siri Remote 的滑動輸入,並透過階層式搜尋來解析「焦點接下來該往哪裡去?」1:

  1. 讀取滑動方向(上、下、左、右)。
  2. 在目前的焦點情境中,找出框架位於目前聚焦視圖所指方向上的可聚焦視圖。
  3. 沿滑動軸線挑選幾何上最接近的視圖(略微偏好與目前視圖中心對齊者)。
  4. 若該方向上沒有可聚焦視圖,該滑動會被消化但焦點不移動。

這代表:可聚焦視圖的視覺布局,與其邏輯階層同等重要。兩個對角線錯開的按鈕會產生模糊的導航結果;兩個垂直對齊的按鈕則能產生可預期的上下移動。HIG 對網格與清單建議的模式是先對齊、再裝飾。

應用程式透過 SwiftUI 的焦點修飾器與引擎互動。預設行為是:具備明確互動意圖的視圖(ButtonNavigationLinkTextField)為可聚焦;靜態視圖(TextImage,以及 VStack 等容器視圖)則否。

讓自訂視圖可聚焦

.focusable() 修飾器會將視圖標記為焦點目標5。可選的布林參數可條件式地控制可聚焦性:

struct PosterCard: View {
    let movie: Movie
    @FocusState private var isFocused: Bool

    var body: some View {
        VStack {
            Image(movie.posterName)
                .resizable()
                .aspectRatio(2/3, contentMode: .fit)
            Text(movie.title)
                .font(.headline)
        }
        .focusable(true)
        .focused($isFocused)
        .scaleEffect(isFocused ? 1.1 : 1.0)
        .animation(.spring(), value: isFocused)
    }
}

該視圖成為引擎可落腳的焦點目標。此模式適用於可點擊卡片、自訂按鈕,以及任何應接受使用者注意力的複合視圖。若不加 .focusable(),引擎會跳過這組 Image + Text 的組合。

@FocusState.focused(_:equals:) 進行程式化控制

當應用程式需要主動指派焦點時(導航轉換之後、搜尋送出之後、關閉 modal 之後),@FocusState 就是 SwiftUI 的基本元件2:

struct LoginView: View {
    enum Field { case username, password, submit }
    @FocusState private var focusedField: Field?
    @State private var username = ""
    @State private var password = ""

    var body: some View {
        VStack {
            TextField("Username", text: $username)
                .focused($focusedField, equals: .username)

            SecureField("Password", text: $password)
                .focused($focusedField, equals: .password)

            Button("Sign In") { /* ... */ }
                .focused($focusedField, equals: .submit)
        }
        .onAppear {
            focusedField = .username
        }
    }
}

@FocusState 列舉值追蹤目前聚焦的欄位;以程式設定新值即可將焦點移至對應視圖。慣例是使用 Hashable 列舉 case;若多個欄位共用相同 case 值,將會造成歧義。

對單一可聚焦視圖而言,@FocusState var isFocused: Bool 加上 .focused($isFocused) 是更簡單的形式。當問題是「這個視圖是否被聚焦?」,布林版本最合適;當問題是「這組之中是哪一個?」,則使用列舉版本。

.focusSection() 進行群組化

若不加 .focusSection(),每個可聚焦視圖都會在引擎的幾何搜尋中以同一層級參與。加上之後,容器會成為一個焦點群組:進出該區段是一次決策,區段內的導航則是另一次3。請注意,.focusSection() 僅適用於 tvOS 與 macOS;在 iOS、iPadOS、watchOS 或 visionOS 上沒有效果。

HStack {
    VStack {
        Button("Settings") { ... }
        Button("Profile") { ... }
        Button("Logout") { ... }
    }
    .focusSection()

    VStack {
        ContentList(items: items)
    }
    .focusSection()
}

兩個 VStack 變得能以單位形式被導航。使用者從側邊欄向右滑動進入內容區;一旦進入,引擎便處理區內導航。若不加 .focusSection(),從側邊欄按鈕滑出時,可能會落到某個碰巧在幾何上最近的內容項目上,使 UX 顯得隨機。

正確模式是:每個具備內部焦點結構的 UI 區域(側邊欄、卡片網格、分頁列、分頁控制項)都應在其容器上加 .focusSection() 修飾器。引擎接著會在巨觀層級於各區段間導航,在微觀層級於區段內導航。

.prefersDefaultFocus(_:in:) 設定初始焦點

當畫面出現或彈出視窗開啟時,某個元件需要接收初始焦點。若沒有明確指引,引擎會挑選版面中第一個可聚焦的視圖,而這通常是錯的(挑到返回按鈕,而非主要動作;挑到不顯眼的清單格,而非播放按鈕)4

struct MovieDetailView: View {
    let movie: Movie
    @Namespace private var detailNamespace

    var body: some View {
        VStack {
            HStack {
                Button("Back") { ... }
                Spacer()
            }

            PosterImage(movie: movie)

            Button("Play") { ... }
                .prefersDefaultFocus(in: detailNamespace)

            Button("Add to Watchlist") { ... }
        }
        .focusScope(detailNamespace)
    }
}

@Namespace 加上 .focusScope() 定義焦點邊界,而 .prefersDefaultFocus(in:) 則宣告該範圍內偏好的初始焦點。當畫面出現時,焦點會落在 Play 上。

對於使用者進入時帶有明確「該先做什麼」期望的視圖,此模式正是正確選擇:電影詳細頁的 Play、登入畫面的 Sign In、引導畫面的 Get Started。

自訂焦點效果(以及何時停用預設)

系統焦點效果是聚焦視圖周圍那道柔邊發光。它會略微放大視圖、加上細緻陰影,並以平台標準時序加以動畫化。對多數應用程式而言,預設效果是正確的;它與其他所有 tvOS 應用程式一致,讓使用者得以學習平台的詞彙。

對於需要自訂焦點視覺的應用程式(品牌特有的發光、內容感知的效果、與預設衝突的焦點環),.focusEffectDisabled() 可選擇退出系統處理6:

Button {
    play(movie)
} label: {
    PosterImage(movie: movie)
        .overlay(focusBorder)
        .scaleEffect(isFocused ? 1.05 : 1.0)
}
.focusEffectDisabled()
.focused($isFocused)

自訂視圖須自行負責以視覺方式表達焦點;系統不再介入。代價是:每個焦點視覺都必須由應用程式自行設計與實作,而非繼承。對多數應用程式來說,系統效果才是正確選擇。

tvOS 上常見的焦點失誤

三種會造成糟糕 tvOS UX 的模式:

不接受焦點的按鈕。HStack { Image; Text } 渲染、卻未加 .focusable() 的自訂按鈕,在引擎眼中是不存在的。Siri Remote 的滑動會跳過它。修正方式:將互動內容包進 Button(預設提供焦點參與),或明確套用 .focusable()

焦點陷阱。接受焦點卻沒有出口的視圖(沒有可聚焦的左/右/上/下相鄰視圖、沒有透過 Menu 鈕的逃離方式),會讓使用者卡住。修正方式:每個焦點情境都應有明確記錄的離開路徑。.focusSection() 模式有助於此,因為它給引擎一個可逃離的單位。

預設焦點落在錯的元件上。電影詳細頁開啟時焦點落在 Back 而非 Play,是使用者每次造訪都得付出的摩擦成本。修正方式:在主要動作上宣告 .prefersDefaultFocus(in:)

無法存取的自訂焦點效果。僅僅是 1pt 低對比色邊框的焦點環,在無障礙性上是失敗的。系統焦點效果經過高對比與動態測試;自訂替代方案需要同等的講究。本系列的無障礙作為平台特性一文涵蓋更廣的原則。

tvOS 何時值得佔有一個位置

本系列的 Apple 平台矩陣一文論述過,tvOS 是相對於 iOS 而言安裝基數最小的平台,應用程式必須具備真正的「向後靠」或「沙發模式」使用情境,才值得這份工程投入。焦點引擎正是該投入的一部分:不遵守焦點詞彙的 tvOS 應用程式,會像被拉伸到電視上的 iPad 應用程式。這份投入是真實的,因為API層是真實的;這份工程工作有意義,因為引擎確實會決定焦點該往哪裡去。

值得佔有 tvOS 位置的應用程式,通常具備三項特質: 1. 以看電視的距離消費內容。串流、相片幻燈片、控制器驅動的遊戲。 2. 稀疏的互動模型。每個畫面僅有少數主要動作,以方向輸入導航。 3. 向後靠的使用情境。使用者坐在沙發上,可能同時用另一裝置多工,可能只是半看狀態。

對於屬於這些類別的應用程式,焦點引擎的投入是正確的。對於不符合者(生產力工具、需要精細操作的創作型應用、文字輸入吃重者),正確的決策是跳過 tvOS,如該矩陣文章所建議。

此模式對 tvOS 應用程式的意義

三點要點。

  1. 將焦點意圖建構進版面中,而非事後補救。使用者會從哪裡開始?能往哪裡去?主要動作是什麼?在 tvOS 上設計畫面,要從焦點流動開始,而非視覺構圖。視覺接續其後。

  2. 對任何具有內部結構的區域,積極使用 .focusSection()對網格、側邊欄、分頁列而言,預設的幾何導航往往是錯的。區段修飾器很小,但帶來的差異很大。

  3. 除非有真正理由替換,否則保留系統焦點效果。自訂焦點視覺意味著真實的工程工作,加上無障礙性工作,加上跨主題測試。系統效果是正確的預設;唯有設計確實需要自訂處理時,才動用 .focusEffectDisabled()

完整的 Apple 生態系列:強型別的 App Intents;MCP 伺服器;路由問題;Foundation Models;執行階段與工具LLM之分;三個介面;單一事實來源模式;兩個 MCP 伺服器;Apple 開發的 hooks;Live Activities;watchOS 執行階段;SwiftUI 內部運作;RealityKit 的空間心智模型;SwiftData 結構紀律;Liquid Glass 模式;多平台出貨;平台矩陣;Vision 框架;Symbol Effects;Core ML 推論;Writing Tools API;Swift Testing;Privacy Manifest;無障礙作為平台特性;SF Pro 字體系統;visionOS 空間模式;Speech 框架;SwiftData 遷移;我拒絕撰寫的主題。中樞頁在 Apple 生態系系列。如需 iOS 與 AI agents 的更廣脈絡,請見 iOS Agent 開發指南

FAQ

.focusable() 在 iOS 上能用嗎?

可以,但其行為在 iOS 上是針對鍵盤與指標互動(藍牙鍵盤、iPadOS 指標、iPad Magic Keyboard),而不是 tvOS 所使用的焦點引擎驅動導航。同一份程式碼可跨平台使用;但使用者面對的互動有所不同。在 tvOS 上,.focusable() 是主要路徑。在 iOS 上,則是無障礙性的補充提示。

.focusable()Button 有何差別?

Button 是更高階的構造,內含可聚焦性、動作處理、系統按鈕樣式與無障礙特性。.focusable() 僅是低階標記,只將視圖設為焦點目標。當視圖在邏輯上就是按鈕時,使用 Button;當你正在打造不符合按鈕心智模型的自訂互動視圖(海報卡、網格中的方塊)時,使用 .focusable()

可以有多個 .prefersDefaultFocus 宣告嗎?

可以,以 @Namespace 限定範圍。每個焦點範圍都可有自己偏好的預設。此模式適用於巢狀情境(畫面內的彈出視窗、側邊欄內的分頁):每個範圍各自挑選自己的初始焦點。

如何處理含有大量項目的清單焦點?

SwiftUI 的清單預設可聚焦;引擎會自動處理跨儲存格的上下導航。對於自訂的類清單版面,將每個儲存格包進 Button 或套用 .focusable(),然後將整個清單置入 .focusSection() 中,讓引擎將該清單視為相對於其他 UI 區域的單一單位。

Siri Remote 的 Menu 鍵在 tvOS 上是「關閉/返回」動作。它會彈出 navigation stack、退出 modal、回到上層情境。SwiftUI 透過 NavigationStack 與標準 modal 關閉自動處理之;應用程式通常不會攔截。如需自訂關閉邏輯,onExitCommand 視圖修飾器可捕捉按下事件。

這與本系列的其他平台文章有何關聯?

tvOS 的焦點引擎是該平台特定的導航介面,與 visionOS 的注視加捏合(於 visionOS 空間模式中介紹)以及 iOS 的點擊加捲動,並列為各平台的輸入隱喻。每個平台都有自己的輸入隱喻;本系列的 Apple 平台矩陣一文論述,平台納入必須尊重該隱喻,而焦點引擎正是 tvOS 所要求的。

參考資料


  1. Apple Developer:App Programming Guide for tvOS, Controlling the User Interface with the Apple TV Remote。焦點引擎模型與幾何解析規則。 

  2. Apple Developer Documentation:@FocusState。用於追蹤並以程式化方式跨 SwiftUI 平台指派焦點的 property wrapper。 

  3. Apple Developer Documentation:focusSection()。將可聚焦後代視圖組成單一焦點目標、以利區段間導航的視圖修飾器。 

  4. Apple Developer Documentation:prefersDefaultFocus(_:in:)focusScope(_:)。預設焦點宣告搭配 namespace 限定的焦點邊界。 

  5. Apple Developer Documentation:focusable(_:)。將視圖標記為焦點目標的視圖修飾器,可選擇性地以布林條件控制。 

  6. Apple Developer Documentation:focusEffectDisabled(_:)。系統焦點效果的退出選項(Bool 預設為 true);需要時搭配自訂焦點視覺使用。 

相關文章

Accessibility As Platform: Personal Voice, Live Speech, Eye Tracking, Music Haptics

Personal Voice, Live Speech, Eye Tracking, Music Haptics, Vocal Shortcuts: accessibility as platform features, not app r…

14 分鐘閱讀

SF Pro: Variable Axes, Optical Sizing, And The Dynamic Type Contract

Apple's system font ships with three variable axes and continuous optical sizing. The vocabulary that makes typography w…

12 分鐘閱讀

The Design Engineer's Agent Stack

Design engineers need agent infrastructure that enforces visual consistency, typography discipline, color compliance, an…

14 分鐘閱讀