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:
- 讀取滑動方向(上、下、左、右)。
- 在目前的焦點情境中,找出框架位於目前聚焦視圖所指方向上的可聚焦視圖。
- 沿滑動軸線挑選幾何上最接近的視圖(略微偏好與目前視圖中心對齊者)。
- 若該方向上沒有可聚焦視圖,該滑動會被消化但焦點不移動。
這代表:可聚焦視圖的視覺布局,與其邏輯階層同等重要。兩個對角線錯開的按鈕會產生模糊的導航結果;兩個垂直對齊的按鈕則能產生可預期的上下移動。HIG 對網格與清單建議的模式是先對齊、再裝飾。
應用程式透過 SwiftUI 的焦點修飾器與引擎互動。預設行為是:具備明確互動意圖的視圖(Button、NavigationLink、TextField)為可聚焦;靜態視圖(Text、Image,以及 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 應用程式的意義
三點要點。
-
將焦點意圖建構進版面中,而非事後補救。使用者會從哪裡開始?能往哪裡去?主要動作是什麼?在 tvOS 上設計畫面,要從焦點流動開始,而非視覺構圖。視覺接續其後。
-
對任何具有內部結構的區域,積極使用
.focusSection()。對網格、側邊欄、分頁列而言,預設的幾何導航往往是錯的。區段修飾器很小,但帶來的差異很大。 -
除非有真正理由替換,否則保留系統焦點效果。自訂焦點視覺意味著真實的工程工作,加上無障礙性工作,加上跨主題測試。系統效果是正確的預設;唯有設計確實需要自訂處理時,才動用
.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 區域的單一單位。
Menu 鍵在焦點模型中扮演什麼角色?
Siri Remote 的 Menu 鍵在 tvOS 上是「關閉/返回」動作。它會彈出 navigation stack、退出 modal、回到上層情境。SwiftUI 透過 NavigationStack 與標準 modal 關閉自動處理之;應用程式通常不會攔截。如需自訂關閉邏輯,onExitCommand 視圖修飾器可捕捉按下事件。
這與本系列的其他平台文章有何關聯?
tvOS 的焦點引擎是該平台特定的導航介面,與 visionOS 的注視加捏合(於 visionOS 空間模式中介紹)以及 iOS 的點擊加捲動,並列為各平台的輸入隱喻。每個平台都有自己的輸入隱喻;本系列的 Apple 平台矩陣一文論述,平台納入必須尊重該隱喻,而焦點引擎正是 tvOS 所要求的。
參考資料
-
Apple Developer:App Programming Guide for tvOS, Controlling the User Interface with the Apple TV Remote。焦點引擎模型與幾何解析規則。 ↩↩↩
-
Apple Developer Documentation:
@FocusState。用於追蹤並以程式化方式跨 SwiftUI 平台指派焦點的 property wrapper。 ↩↩ -
Apple Developer Documentation:
focusSection()。將可聚焦後代視圖組成單一焦點目標、以利區段間導航的視圖修飾器。 ↩↩ -
Apple Developer Documentation:
prefersDefaultFocus(_:in:)與focusScope(_:)。預設焦點宣告搭配 namespace 限定的焦點邊界。 ↩↩ -
Apple Developer Documentation:
focusable(_:)。將視圖標記為焦點目標的視圖修飾器,可選擇性地以布林條件控制。 ↩ -
Apple Developer Documentation:
focusEffectDisabled(_:)。系統焦點效果的退出選項(Bool 預設為true);需要時搭配自訂焦點視覺使用。 ↩