
こんにちは、OTT サービス技術部の宮田です。
今回はAmazonから発表された新型FireTVのOSである「Vega OS」向けアプリの開発にチャレンジする中でハマったことと、その解決策を一つ紹介します。
本題に入る前に
本題に入る前に、状況を説明させていただきます。
タイトルで触れているLinuxTVとは、Linux系OSが搭載されているTVを指し、アプリはHTMLで動作するWebアプリを用意するのが一般的です。AndroidTV系ではないTVですが、動画配信サービスが利用できる場合はこのLinuxTVのパターンになります。
その一方で、AmazonはFireTV向けの新OSである「Vega OS」を発表しました。Vega用アプリの開発にはReact Nativeを採用しており、Amazonが用意したReact NativeをVega向けに拡張したモジュールと組み合わせて開発を行います。
私のチームではLinuxTV向けアプリをReactで開発していたため、React -> React Nativeであれば学習コストも比較的低いだろうという判断から、私がこのプロジェクトにアサインされました。これにより、長年培ってきたLinuxTVでの知見を活かしつつ、新しい開発環境に挑戦することになりました。
また、今回のプロジェクトではSDUI (Server-Driven UI)が採用されており、特定のお客様向けのアプリを構築するのとはまた違った難しさもありました。
SDUIの説明についてはインターン畑さんの記事を参照してください。
今回は、その中で特に苦戦した、TVアプリにおけるアイテムのフォーカス管理について語りたいと思います。
フォーカス管理
TVアプリ開発で最も重要な機能の一つが、リモコンの十字キーによる「フォーカス管理」です。Webアプリやスマホアプリの場合、ユーザーはマウスやタッチ、スワイプ操作でスクロールやアイテム選択を行いますが、TVアプリの場合はリモコンの上下左右キーと決定キーで行う必要があります。そのため、フォーカスがユーザーの期待通りに移動することが、アプリの使いやすさに直結します。
どういう仕組みでキー入力とアイテムのフォーカスを管理するか、私たちは当初、使い慣れたLinuxTVのノウハウを転用する形で、ほぼそのままの思想で実装を進めました。
LinuxTVでのフォーカス管理手法
LinuxTV(Webアプリ)でのフォーカス管理は、コンテンツやアイテムの要素の並びを多次元配列として捉えていました。例えば縦をy、横をx座標として表現し、キー入力を監視します。入力方向(上下左右)に合わせてプログラム側でx, yのインデックスを増減させ、アイテムが持つx,y座標と、プログラムが算出したインデックスのx,yが一致するアイテムに、手動でフォーカスを渡します。
使用感としては、キー入力すると先に「インデックス」が移動し、そのインデックスの変更をトリガーにして、対応する要素の「フォーカス状態」が変わる、というイメージでした。
自前フォーカス管理の問題点
しかし、このLinuxTVの手法をVega(React Native)に持ち込むと、大きな壁にぶつかりました。
Webアプリであれば、各要素からonKeyDownなどでキー入力イベントを取得できます。また、イベントバブリング(イベントが親要素に伝播する仕組み)があるので、実際に反応した要素の親要素でもイベントハンドラーが発火します。
一方、Vegaでは、キー入力は専用のカスタムフック「useTVEventHandler*1」を利用することでキー入力イベントの発火自体は取得できますが、このフックにはイベントのバブリング機能もキャプチャリング機能もなく、呼び出したすべての箇所で同時に反応してしまいます。
このため、実際にフォーカスしている要素だけハンドラーの処理を進める、という制御が非常に複雑になります。これが並列のアイテムならまだ楽ですが、親子関係の要素の場合、子や孫がキーイベント処理をするかどうかを常に親が管理し続ける必要が出てきてしまいました。
特に今回対応しているプロジェクトでは、SDUIを採用しているため、コンポーネントの親子関係が実際に画面の描画を始めるまでわからないという特性があり、フォーカスとキーイベントの親子関係の構築の難しさに拍車をかけました。
ネイティブでのフォーカス管理
そこで私たちは、自前でのインデックス管理を捨て、React Nativeが提供するネイティブのフォーカスエンジンに処理を委ねることにしました。
React Nativeでは、Touchable系コンポーネントやPressableなどのaccessible というpropsが有効なコンポーネントを配置することで、OSが自動的に「今フォーカスしているアイテムの上下左右にある、一番近いaccessibleなコンポーネント」にフォーカスを移動させてくれます。フォーカスが移動した後にそのコンポーネント自身が「今フォーカスされましたよ(onFocus)」「今フォーカスが外れましたよ(onBlur)」と、イベントを通じて自己申告するイメージです。(厳密に言えばReact Nativeの基本的なコンポーネントであるViewもaccessibleを有効にできますが、可読性の観点でおすすめはしません)
使用感としては、キー入力すると先にフォーカスが移動し、新しいフォーカスに合わせてステートを変える形になります。
結果として、キー入力そのものを私たちが管理する必要がなくなり、複雑だったハンドラー関数が不要になりました。
ネイティブフォーカスの問題
しかし、この「ネイティブフォーカス管理」も万能ではありませんでした。
「一番近いaccessibleなコンポーネントにフォーカスが移動する」という仕様が、TVアプリ特有のUIにおいて想定外の挙動を示すことが判明したのです。
例えば、こちらの画像をご覧ください。
一般的なサイドメニューとメインエリアで構成されたTVアプリの画面です。

サイドメニューの中央付近にフォーカスがある状態です。
この状態で右キーを押した場合、ユーザーの期待値としてはメインエリアの「トップコンテンツ」部分、画面上部から順にフォーカスしてほしい、と考えるのが自然です。
しかし実際には…

このように、ネイティブフォーカスエンジンは「今フォーカスしているアイテムの右側で、一番物理的に近いアイテム」へとフォーカスを移動させてしまいます。結果として、コンテンツリストの一番左上のアイテムにフォーカスが当たってしまい、期待と異なる挙動となってしまいました。
TVFocusGuideView
この問題の解決策として、「TVFocusGuideView*2」というコンポーネントが提供されています。
TVFocusGuideViewは、フォーカス移動のロジックをより細かく制御するための特別なコンポーネントです。これにより、開発者はネイティブフォーカスエンジンの自動挙動に対し、ある程度の「ガイド」や「制約」を設けることができます。特に、サイドバーからメインコンテンツへの移動など、複数のフォーカス領域が隣接している場合に、ユーザーの期待に沿った自然なフォーカス遷移を実現するために使用されます。
このコンポーネントは、主に以下の3つの主要な機能を提供しています。
-
autoFocus:
このプロパティをtrueに設定すると、TVFocusGuideViewがマウントされた際に、その中に含まれるフォーカス可能な要素の中から、最初の要素に自動的にフォーカスを移動させることができます。これは、画面が表示された直後に特定のボタンや要素にフォーカスを当てたい場合に非常に便利です。また、この中の最初にフォーカスされた子要素を記憶し、再度フォーカスされた際にはその子要素へフォーカスをリダイレクトします。後述するdestinationsプロパティとこのプロパティを併用する場合、destinationsが優先されます。 -
trapFocus:
このプロパティをtrueに設定すると、TVFocusGuideViewで囲まれた領域内でフォーカスが「閉じ込められる」ように動作します。つまり、フォーカスがこの領域から外に出られなくなり、領域内の要素間でのみフォーカスが循環するようになります。サイドメニューのように、特定の領域内で完結するナビゲーションを実現したい場合に有効です。 -
destinations:
このプロパティには、フォーカスさせたいコンポーネントのRef(参照)を配列として渡します。TVFocusGuideViewは、それ自体がフォーカスを受け取ることができ(多くの場合、UI上は見えない透明なViewとして配置されます)、このTVFocusGuideViewにフォーカスが当たった際に、destinationsプロパティに指定されたコンポーネントのいずれかにフォーカスを即座に「リダイレクト」する役割を果たします。
これらの機能を駆使することで、先ほどの「サイドメニューからメインエリアへのフォーカス移動」の問題を解決できます。例えば、メインエリア全体をTVFocusGuideViewでラップしておき、destinationsにHeroImageコンポーネントのRefを指定しておきます。サイドメニューから右キーを押してフォーカスがメインエリア側へ移動し、メインエリアをラップするTVFocusGuideViewに当たった瞬間に、destinationsの設定に従ってHeroImageコンポーネントにフォーカスがリダイレクトされる、といった制御が可能になります。
おわりに
これまで、LinuxTV開発の経験を持つ私が、React NativeベースのVegaとどのように向き合い、特に「フォーカス管理」というTVアプリ開発の核心的な課題にどう挑んできたかをご紹介しました。
LinuxTVでの「多次元配列(インデックス)で能動的にフォーカスを制御する」という長年培った常識は、今回のプロジェクトのように「コンポーネントの親子関係が実際に画面の描画を始めるまでわからない」という特性を持つ環境では、その採用が難しいことがわかりました。
次に採用したネイティブフォーカスエンジンは、その自動化された挙動(「一番近い要素」へ移動)が、今度はTVアプリ特有のUI(サイドメニューなど)において、我々の期待する振る舞いと衝突するという新たな課題を生みました。
この経験から、TVFocusGuideViewのような、ネイティブの挙動をただ利用するだけでなく、その特性を理解した上で適切に「ガイド」し、時には「制約(trap)」を与えるコンポーネントを使いこなすことが、Vega開発における鍵であると痛感しています。
Reactの知識は確かに流用できますが、OSネイティブの「お作法」を深く理解し、Web(LinuxTV)とネイティブ(Vega)の思想の違いを乗り越えていくことこそが、このプロジェクトの面白さであり、難しさでもあると感じています。
この挑戦の記録が、今後Vega OSでの開発に取り組む方々にとって、何かしらの参考になれば幸いです。
また、タイトルを「フォーカス編」としているので、続きがあるやもしれません。
最後までお読みいただき、ありがとうございました。
元の記事を確認する