← すべての記事

tvOS Focus Engine:Siri Remote向けSwiftUIパターン

Apple TVはタッチ面を持たない唯一のApple プラットフォームです。ユーザーはSiri Remoteの方向スワイプとボタン押下でナビゲートし、すべてのインタラクションはフォーカスエンジンを経由します。これは、ジオメトリ、階層、開発者が宣言したフォーカス構造に基づいて、次にどの要素がフォーカスを受け取るかを決定するシステムです1。tvOS上のSwiftUIは、エンジンを扱うためのフォーカス特化(駄洒落で恐縮です)した語彙を提供しています。.focusable@FocusState.focused.focusSection.prefersDefaultFocus、そして.focusEffectDisabledです。この語彙を採用したアプリはネイティブに感じられます。これに逆らうアプリは、ユーザーの期待する場所へナビゲートしないリモコンという体験を生み出してしまいます。

本記事では、フォーカスエンジンAPIサーフェスを、実戦投入可能なパターンとともに歩いていきます。フレームは「エンジンが前提とすることと、SwiftUIがそれにどう協調させるか」です。タップ&スクロールでiOSにおいて機能するフォーカス設計が、tvOSではしばしば失敗するからです。クラスター記事のApple Platform Matrixでは、tvOSはフォーカスを意識したUIによってのみその枠を獲得できると論じました。

TL;DR

  • フォーカスエンジンはジオメトリによってフォーカスを解決します。スワイプ方向で最も近いフォーカス可能なビューを選びます1。アプリはフォーカス可能なビュー、フォーカスセクション、デフォルトフォーカスのターゲットを宣言することで協調します。
  • @FocusState(と.focused(_:equals:))はプログラム的なフォーカス制御のためのSwiftUIプリミティブです。同じプロパティラッパーがiOS、macOS、watchOS、tvOSで動作しますが、その真価が発揮されるのはtvOSです2
  • .focusSection()は複数のフォーカス可能なビューを単一のフォーカスターゲットにグループ化し、セクション間のナビゲーションに使われます。セクション内ではエンジンに選ばせます3。ボタン列、カードグリッド、サイドバーセクションに使いましょう。
  • .prefersDefaultFocus(_:in:)は、ユーザーがコンテキスト(画面、ポップオーバー、タブ)に入った時にどのビューがフォーカスを受け取るかを宣言します。デフォルトのスコープを定めるため、@Namespaceと組み合わせて使います4
  • システムのフォーカスエフェクト(フォーカスされたビューの周りに広がるハイライト)は自動です。カスタムのフォーカス表現を実装する場合のみ、.focusEffectDisabled()で無効にしてください。それ以外では、プラットフォームネイティブのエフェクトが正解です。

フォーカスエンジンはどう判断するのか

フォーカスエンジンはSiri Remoteからのスワイプ入力を処理し、階層的な検索を通じて「次にフォーカスはどこへ行くのか」を解決します1

  1. スワイプ方向(上、下、左、右)を読み取ります。
  2. 現在のフォーカスコンテキスト内で、現在フォーカスされているビューに対してその方向にフレームがあるフォーカス可能なビューを見つけます。
  3. スワイプ軸に沿って幾何学的に最も近いものを選びます(現在のビューの中心との位置合わせを保つわずかなバイアス付き)。
  4. その方向にフォーカス可能なビューが存在しない場合、スワイプはフォーカスを移動させずに消費されます。

含意は次の通りです。フォーカス可能なビューの視覚的レイアウトは、その論理的階層と同じくらい重要となります。斜めにオフセットされた2つのボタンは曖昧なナビゲーションを生み出し、垂直に整列された2つのボタンは予測可能な上下ナビゲーションを生み出します。グリッドやリストにおけるHIG推奨パターンは、まず整列、次に装飾です。

アプリはSwiftUIのフォーカスモディファイアを通じてエンジンに参加します。デフォルトの動作では、明示的にインタラクティブな意図を持つビュー(ButtonNavigationLinkTextField)はフォーカス可能で、静的なビュー(TextImageVStackのようなコンテナビュー)はフォーカス可能ではありません。

カスタムビューをフォーカス可能にする

.focusable()モディファイアはビューをフォーカスターゲットとしてマークします5。オプションのBooleanパラメータでフォーカス可能性を条件付けます:

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:)

アプリがフォーカスを指示する必要がある場合(ナビゲーション遷移後、検索送信後、モーダル解除後)、@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のenum値はどのフィールドがフォーカスされているかを追跡し、新しい値をプログラム的に代入することで、対応するビューにフォーカスを移動させます。Hashableなenumケースが慣例となります。同じケース値を持つ複数のフィールドは曖昧になってしまうためです。

単一のフォーカス可能なビューには、@FocusState var isFocused: Bool.focused($isFocused)の組み合わせが、よりシンプルな形となります。Boolean形式は「このビューはフォーカスされているか?」という質問に適しています。enum形式は「このセットの中でどのビューか?」に適しています。

グルーピングのための.focusSection()

.focusSection()がない場合、フォーカス可能なすべてのビューはエンジンの幾何学的検索に同じレベルで参加します。これを使うと、コンテナがフォーカスグループになります。セクションへの/からのナビゲーションは1つの判断、セクション内のナビゲーションはまた別の判断となります3.focusSection()はtvOSとmacOSのみであることに注意してください。iOS、iPadOS、watchOS、visionOSでは効果がありません。

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

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

2つの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を生み出す3つのパターンを挙げます。

フォーカスを受け取らないボタン。 HStack { Image; Text }として.focusable()なしでレンダリングされたカスタムボタンは、エンジンには見えません。Siri Remoteのスワイプはこれをスキップします。修正:インタラクティブなコンテンツをButton(デフォルトでフォーカス参加を提供します)でラップするか、.focusable()を明示的に適用します。

フォーカスの罠。 フォーカスを受け取るが脱出経路を提供しないビュー(フォーカス可能な左右上下の兄弟がない、Menuボタンによる脱出もない)は、ユーザーを立ち往生させます。修正:すべてのフォーカスコンテキストには文書化された出口経路があるべきです。.focusSection()パターンが役立ちます。エンジンに脱出先のユニットを与えるからです。

間違った要素へのデフォルトフォーカス。 Playではなく戻るボタンにフォーカスして開く映画詳細画面は、ユーザーが訪問のたびに支払うフリクションです。修正:プライマリアクションに.prefersDefaultFocus(in:)を宣言します。

アクセシブルでないカスタムフォーカスエフェクト。 低コントラストの1ptの色境界線にすぎないフォーカスリングはアクセシビリティに失敗します。システムのフォーカスエフェクトは高コントラストでモーションテスト済みです。カスタム代替には同等の配慮が必要です。クラスター記事のAccessibility as platformでは、より広い原則を扱っています。

tvOSがその枠を獲得する時

クラスター記事のApple Platform Matrixでは、tvOSはiOSに対して最小のインストールベースを持つプラットフォームであり、エンジニアリング投資を正当化するためにアプリには本物の「リーンバック」または「カウチモード」のユースケースが必要だと論じました。フォーカスエンジンはその投資の一部です。フォーカスの語彙を尊重しないtvOSアプリは、TV画面に引き伸ばされたiPadアプリのように感じられてしまいます。APIサーフェスが本物だからこそ、投資も本物となります。エンジンが実際にフォーカスの行き先を決定するからこそ、エンジニアリング作業は意味を持ちます。

tvOSの枠を獲得するアプリには、3つの特性が共通する傾向があります: 1. TV視聴距離で消費されるコンテンツ。 ストリーミング、写真スライドショー、コントローラ駆動のゲーム。 2. 疎なインタラクションモデル。 画面ごとに少数のプライマリアクション、方向入力でのナビゲーション。 3. リーンバックのユースケース。 ユーザーはカウチに座っており、別のデバイスでマルチタスクしている可能性も、半分だけ視聴している可能性もあります。

これらのカテゴリのアプリには、フォーカスエンジンへの投資は正解です。当てはまらないアプリ(生産性ツール、細かい粒度のクリエイティブアプリ、テキスト入力が多いもの)には、マトリックス記事が推奨するように、tvOSをスキップするのが正解です。

このパターンがtvOSアプリにもたらすもの

3つの要点があります。

  1. フォーカスの意図をレイアウトに組み込みましょう。事後の修正ではなく。 ユーザーはどこから始めるのか?そこからどこへ行けるのか?プライマリアクションは何か?tvOSで画面を設計することは、視覚的な構成ではなくフォーカスフローから始まります。視覚はそれに従います。

  2. 内部構造を持つあらゆるリージョンには.focusSection()を積極的に使いましょう。 デフォルトの幾何学的ナビゲーションは、グリッド、サイドバー、タブバーではしばしば間違いとなります。セクションモディファイアは小さく、その差は大きいのです。

  3. 本当に置き換える理由がない限り、システムのフォーカスエフェクトを保ちましょう。 カスタムフォーカス表現は、エンジニアリング作業に加え、アクセシビリティ作業に加え、すべてのテーマでのテストとなります。システムエフェクトが正しいデフォルトです。デザインが本当にカスタム処理を必要とする場合のみ、.focusEffectDisabled()に手を伸ばしてください。

Apple Ecosystemクラスターの全記事はこちらです:型付きApp IntentsMCPサーバールーティングの問いFoundation ModelsランタイムとツーリングLLMの区別3つのサーフェス単一の信頼できる情報源パターン2つのMCPサーバーApple開発のためのフックLive ActivitieswatchOSランタイムSwiftUIの内部RealityKitの空間メンタルモデルSwiftDataスキーマ規律Liquid Glassパターンマルチプラットフォーム出荷プラットフォームマトリックスVisionフレームワークSymbol EffectsCore MLオンデバイス推論Writing Tools APISwift TestingPrivacy ManifestプラットフォームとしてのアクセシビリティSF ProタイポグラフィvisionOS空間パターンSpeechフレームワークSwiftDataマイグレーション書くことを拒否するもの。ハブはApple Ecosystemシリーズにあります。AIエージェントを使ったiOSのより広い文脈については、iOS Agent Developmentガイドをご覧ください。

FAQ

.focusable()はiOSで動きますか?

はい、ただしiOSにおける動作は、tvOSが使うフォーカスエンジン駆動のナビゲーションではなく、キーボードとポインタのインタラクション(Bluetoothキーボード、iPadOSポインタ、iPad Magic Keyboard)を対象としたものです。同じコードをクロスプラットフォームで使えますが、ユーザー側のインタラクションは異なります。tvOSでは.focusable()が主たる経路です。iOSではアクセシビリティのための補助的なアフォーダンスとなります。

.focusable()Buttonの違いは何ですか?

Buttonは、フォーカス可能性、アクション処理、システムボタンスタイル、アクセシビリティトレイトを含む、より高レベルな構築物です。.focusable()はビューを単にフォーカスターゲットにするだけの低レベルなマーカーです。ビューが論理的にボタンである場合はButtonを、ボタンのメンタルモデルに当てはまらないカスタムインタラクティブビュー(ポスターカード、グリッドのタイル)を構築している場合は.focusable()を使ってください。

.prefersDefaultFocusを複数宣言できますか?

はい、@Namespaceによってスコープされます。各フォーカススコープが独自の優先デフォルトを持てます。このパターンはネストされたコンテキスト(画面内のポップオーバー、サイドバー内のタブ)に適しています。各スコープが独自の初期フォーカスを選びます。

多くの項目を持つリストでフォーカスをどう扱えばよいですか?

SwiftUIのリストはデフォルトでフォーカス可能です。エンジンがセル間の上下ナビゲーションを自動的に処理します。カスタムなリスト風のレイアウトでは、各セルをButtonでラップするか.focusable()を適用し、リスト全体を.focusSection()の中に置いてください。これでエンジンはリストを他のUIリージョンに対する1つのユニットとして扱うようになります。

Siri RemoteのMenuボタンは、tvOS全体での解除/戻るアクションです。ナビゲーションスタックをポップし、モーダルを終了し、親コンテキストに戻ります。SwiftUIはNavigationStackと標準的なモーダル解除を通じて自動的に処理します。アプリが通常これをインターセプトすることはありません。カスタムな解除ロジックには、onExitCommandビューモディファイアが押下を捕捉します。

これはクラスターの他のプラットフォーム記事とどう関係しますか?

tvOSフォーカスエンジンはプラットフォーム固有のナビゲーションサーフェスであり、visionOSの視線とピンチ(visionOS空間パターンで扱っています)やiOSのタップ&スクロールと並行する存在です。各プラットフォームには独自の入力メタファーがあります。クラスター記事のApple Platform Matrixでは、プラットフォームへの参入はそのメタファーを尊重することを要求し、フォーカスエンジンこそtvOSが要求するものだと論じました。

参考文献


  1. Apple Developer:App Programming Guide for tvOS, Controlling the User Interface with the Apple TV Remote。フォーカスエンジンモデルと幾何学的解決ルール。 

  2. Apple Developer Documentation:@FocusState。SwiftUIプラットフォーム全体でフォーカスを追跡しプログラム的に指示するためのプロパティラッパー。 

  3. Apple Developer Documentation:focusSection()。フォーカス可能な子孫を単一のフォーカスターゲットにグループ化するビューモディファイア。セクション間のナビゲーションに使われます。 

  4. Apple Developer Documentation:prefersDefaultFocus(_:in:)focusScope(_:)。namespaceでスコープされたフォーカス境界とペアで使うデフォルトフォーカス宣言。 

  5. Apple Developer Documentation:focusable(_:)。ビューをフォーカスターゲットとしてマークするビューモディファイア。条件付きBooleanがオプションです。 

  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 分で読める