前回に引き続き、AndroidTVのleanbackを読んでいきます。 今回はAndroidTVのアプリ開発をするといつも必要になる「TitleView」のカスタマイズについてです。
これを読んでいる方は、AndroidTVで下のようなレイアウトを実現できるでしょうか。
もしできるのであれば、今回の内容はもう理解されていると思いますので、あまり役に立たないかもしれません。笑
普段Androidアプリの開発をしていて、AndroidTVアプリの開発をしたことがない人には「こんな簡単なこともすぐにできないのか!」と知っていただきたくて載せました。 多分、この辺でつまづいた人も少なくないと思います。「そうそう、それがやりたかったんだよ!」という方はぜひ一読していただけると嬉しいです。読み終わる頃には仕組みが理解できて、すぐに実装できるようになっています。
少しでもAndroidTVで開発するエンジニアの助けになれたら幸いです。
さて、本題に入ります。
TitleViewとは
TitleViewは BrandedFragment
が持つタイトルを表示するためのViewです。アプリや画面の情報を載せておけるViewの一つです。
なので今回の内容はBrandedFragment
を継承したクラスには適応可能です。前回載せたクラス群で確認しましょう。
Fragment ... ├─BrandedFragment(BrandedSupportFragment) │ ├─ErrorFragment(ErrorSupportFragment) │ └─BaseFragment(BaseSupportFragment) │ ├─BrowseFragment(BrowseSupportFragment) │ ├─DetailsFragment(DetailsFragmentBackgroundController) │ └─VerticalGridFragment(VerticalGridSupportFragment) ...
特によく使うコンポーネントの例をあげればBrowseFragment
とVerticalGridFragment
でしょう。それぞれにおけるTitleViewは赤枠で囲った部分になります。
BrowseFragment
VerticalGridFragment
なお、バッジを設定することもできて、バッジを設定するとタイトルの文字は隠されます。
バッジを設定したとき
デフォルトの機能で開発者が変更可能なのは
- タイトルの文字の変更
- バッジの設定
SearchOrb
(検索ボタン)の設定
のみです。カスタマイズしないとタイトルの位置を変えるAPIも存在しません。
ですが、便利なのはTitleView
自体をコンテンツの表示に合わせてshow/hideしてくれたりすることです。
しかし、カスタムに対応しなくては大抵のデザイン要件にマッチしない or デザイナーに俺のゴーストがGoogleの意思だと囁いていると説明してお願いして、仕様を変更してもらうしかないですね。
ここを乗り越えて、いい感じにカスタムできると少し周りより違ったアプリになりそうです。今回は最初の動画のようにVirticalGridFragment
をカスタマイズする例を取り上げます。
TitleViewAdapter
を使いカスタムTitleViewを作る
まずはBrandedFragment
を読むとTitleViewAdapter
というクラスがTitleView
へ操作してることがわかります。
BrandedFragment.java
/** * Sets the view that implemented {@link TitleViewAdapter}. * @param titleView The view that implemented {@link TitleViewAdapter.Provider}. */ public void setTitleView(View titleView) { mTitleView = titleView; if (mTitleView == null) { mTitleViewAdapter = null; mTitleHelper = null; } else { mTitleViewAdapter = ((TitleViewAdapter.Provider) mTitleView).getTitleViewAdapter(); mTitleViewAdapter.setTitle(mTitle); mTitleViewAdapter.setBadgeDrawable(mBadgeDrawable); if (mSearchAffordanceColorSet) { mTitleViewAdapter.setSearchAffordanceColors(mSearchAffordanceColors); } ... } }
開発者はTitleViewAdapter
使うことで独自のTitleViewが作成できます。より正確に言えば、独自のViewに対してTitleViewAdapter.Provider
を実装することで実現できます。クラスのドキュメントを見るとSearchOrb
(検索ボタン)は必ず配置しないといけないと書いてありますが、今回は説明を簡単にするためにSearchOrb
は省いています。
(必ず配置というわりにはnullを返しても大丈夫なので、隠せてしまうのは設計ミスかもしれないです)
先ほどの動画で用いていたCustomTitleView
のレイアウトはこちらです。databinding使っています。
layout_custom_title.xml
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" > <RelativeLayout android:orientation="vertical" android:layout_width="match_parent" android:layout_height="wrap_content" > <TextView android:id="@+id/title" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginBottom="16dp" android:layout_marginStart="4dp" android:layout_marginTop="24dp" android:textColor="@android:color/white" android:textSize="24sp" tools:text="Title" /> <TextView android:id="@+id/button_1" android:layout_width="wrap_content" android:layout_height="48dp" android:layout_marginStart="4dp" android:focusable="true" android:gravity="center" android:paddingLeft="24dp" android:paddingRight="24dp" android:layout_below="@id/title" android:text="button1" android:textColor="@android:color/white" android:textSize="18sp" /> <TextView android:id="@+id/button_2" android:layout_width="wrap_content" android:layout_height="48dp" android:layout_marginStart="4dp" android:focusable="true" android:gravity="center" android:paddingLeft="24dp" android:paddingRight="24dp" android:layout_below="@id/title" android:layout_toEndOf="@id/button_1" android:text="button2" android:textColor="@android:color/white" android:textSize="18sp" /> </RelativeLayout> </layout>
フォーカスしたいViewにはandroid:focusable="true"
を忘れないでください。
今回作るクラスはCustomTitleView
とします。
CustomTitleView.kt
class CustomTitleView @JvmOverloads constructor( c: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : LinearLayout(c, attrs, defStyle), TitleViewAdapter.Provider { private val titleTextView: TextView var title: CharSequence get() = titleTextView.text set(titleText) { titleTextView.text = titleText } init { val binding: LayoutCustomTitleBinding = DataBindingUtil.inflate( LayoutInflater.from(context), R.layout.layout_custom_title, this, false) titleTextView = binding.root.findViewById(R.id.title) title = titleTextView.text addView(binding.root) } private val adapter = object : TitleViewAdapter() { override fun getSearchAffordanceView(): View? = null override fun setTitle(titleText: CharSequence?) { this@CustomTitleView.title = titleText.toString() } override fun getTitle(): CharSequence = this@CustomTitleView.title } override fun getTitleViewAdapter(): TitleViewAdapter = adapter }
set/getTitle
というメソッドをオーバーライドしないといけないため、backing propertyなどでゴニョゴニョしていますが、重要なのは以下です。
addView
で独自のViewを追加するTitleViewAdapter
のメンバ(プロパティ)を持つTitleViewAdapter.Provider
のgetTitleViewAdapter
メソッドでそれを返す
今回はSearchOrb
を消していると書きましたが、
override fun getSearchAffordanceView(): View? = null
の部分を変更すればカスタマイズ可能です。次はCustomTitleView
をBrandedFragment
に設定しましょう。
onInflateTitleView
でCustomTitleView
を渡す
今回はVerticalGridFragment
ですが、BrandedFragment
を継承しているクラスならonInflateTitleView
というメソッドをオーバーライドできます。
そこでCustomTitleView
を呼び出したレイアウトのxmlを返すことで適応されます。
view_custom_title.xml
<la.kaelae.customtitleviewsample.CustomTitleView android:id="@+id/browse_title_group" style="?attr/browseTitleViewStyle" xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" />
ここで重要なのはidをbrowse_title_group
にすることです。BrandedFragment
内のタイトルを設定する処理はinstallTitleView
メソッド内で行われ、
そのときsetTitleView(titleLayoutRoot.findViewById(R.id.browse_title_group))
するためです。
BrandedFragment.java
public void installTitleView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) { View titleLayoutRoot = onInflateTitleView(inflater, parent, savedInstanceState); if (titleLayoutRoot != null) { parent.addView(titleLayoutRoot); setTitleView(titleLayoutRoot.findViewById(R.id.browse_title_group)); } else { setTitleView(null); } }
ではview_custom_title.xml
を実際にinflateしましょう。
VerticalGridFragment.kt
override fun onInflateTitleView(inflater: LayoutInflater, parent: ViewGroup, savedInstanceState: Bundle?): View { customTitleView = inflater.inflate(R.layout.view_custom_title, parent, false) val c = parent.context val button1 = customTitleView.findViewById<TextView>(R.id.button_1) button1 .setOnClickListener { _ -> Toast.makeText(c, "button1", Toast.LENGTH_SHORT).show() } val button2 = customTitleView.findViewById<TextView>(R.id.button_2) button2 .setOnClickListener { _ -> Toast.makeText(c, "button2", Toast.LENGTH_SHORT).show() } return customTitleView }
上記のコードでは省略していますが、AndroidTVの場合、フォーカスが当たったときにUIを強調する色の変更などを追加するべきです。
View単体でのフォーカス制御を行なっているwidgetに、SearchOrbView
があるのでコードは載せませんが参考にしてみてください。
ここまでのコードを書くことで、独自のレイアウトをタイトルに設定することが可能です。しかしこれだけでは思ったように動きません。 ボタンへのフォーカスがうまく動かないはずです。AndroidTVではフォーカスの仕組みが思うような操作を邪魔します。
TitleView
内のフォーカスを制御する
そこでTitleView
内のフォーカスをどこで制御しているのかを見つけるのがポイントになります。
まずはVerticalGridFragment
を読んでみるとsetupFocusSearchListener
という怪しいメソッドを見つけます。leanback内では度々登場するのがFocusSearch
というキーワードで、フォーカスがうまく行かないなというときはこれを検索してみるのがおすすめです。
VerticalGridFragment.java
private void setupFocusSearchListener() { BrowseFrameLayout browseFrameLayout = (BrowseFrameLayout) getView().findViewById(R.id.grid_frame); browseFrameLayout.setOnFocusSearchListener(getTitleHelper().getOnFocusSearchListener()); }
onStart
するたびにここを呼び出していますが、わからない部分がいくつか出てきました。BrowseFrameLayout
とはなんでしょうか。
そこでVertivalGridFragment
のレイアウトを確認します。
lb_vertical_grid_fragment.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/browse_dummy" android:layout_width="match_parent" android:layout_height="match_parent" > <android.support.v17.leanback.widget.BrowseFrameLayout android:id="@+id/grid_frame" android:focusable="true" android:focusableInTouchMode="true" android:descendantFocusability="afterDescendants" android:layout_width="match_parent" android:layout_height="match_parent" > <FrameLayout android:id="@+id/browse_grid_dock" android:layout_width="match_parent" android:layout_height="match_parent" /> </android.support.v17.leanback.widget.BrowseFrameLayout> </FrameLayout>
このようにbrowse_grid_dock
というFrameLayout
をラップするように配置してあります。そしてBrowseFrameLayout
のクラスドキュメントには
/** * A ViewGroup for managing focus behavior between overlapping views. */
とあるため、ここでラップしたViewのフォーカスを制御していそうです。あと少しですね。BrowseFrameLayout
のsetOnFocusSearchListener
を読むとmListener
というメンバに代入しています。そこでmListener
の処理をしている箇所を探すと、focusSearch
を見つけます。
BrowseFrameLayout.java
@Override public View focusSearch(View focused, int direction) { if (mListener != null) { View view = mListener.onFocusSearch(focused, direction); if (view != null) { return view; } } return super.focusSearch(focused, direction); }
このfocusSearch
で返却するViewにフォーカスが当たる仕組みですね。ではVerticalGridFragment
で渡していたOnFocusSearchListener
を探すとなぜうまく動かないのかわかりそうです。
もう一度確認すると
VerticalGridFragment.java
private void setupFocusSearchListener() { BrowseFrameLayout browseFrameLayout = (BrowseFrameLayout) getView().findViewById(R.id.grid_frame); browseFrameLayout.setOnFocusSearchListener(getTitleHelper().getOnFocusSearchListener()); }
となっており、getTitleHelper().getOnFocusSearchListener()
なのでTitleHelper
が設定しているOnFocusSearchListener
がそれに当たりそうです。
// When moving focus off the TitleView, this focus search listener assumes that the view that // should take focus comes before the TitleView in a focus search starting at the scene root. private final BrowseFrameLayout.OnFocusSearchListener mOnFocusSearchListener = new BrowseFrameLayout.OnFocusSearchListener() { @Override public View onFocusSearch(View focused, int direction) { if (focused != mTitleView && direction == View.FOCUS_UP) { return mTitleView; } final boolean isRtl = ViewCompat.getLayoutDirection(focused) == ViewCompat.LAYOUT_DIRECTION_RTL; final int forward = isRtl ? View.FOCUS_LEFT : View.FOCUS_RIGHT; if (mTitleView.hasFocus() && (direction == View.FOCUS_DOWN || direction == forward)) { return mSceneRoot; } return null; } };
見つけました。引数にあるfocused
は直前にフォーカスされていたViewであり、direction
はリモコンキー操作の向きです。直前にTitleView
にフォーカスが当たっていないとき、上方向のキー操作を行うとTitleView
にフォーカスが合うような実装になっています。mSceneRoot
はTitleHelper
初期化のときに設定する、TitleView
を持つ親のViewGroupです。この処理を変更すれば、自分が思うようなフォーカス制御ができそうです。
BrowseFrameLayout
にCustomTitleView
のフォーカスの動きを定義したonFocusSearchListener
を設定します。
VerticalGridFragment.kt
override fun onResume() { super.onResume() val browseFrameLayout = view.findViewById<BrowseFrameLayout>(R.id.grid_frame) browseFrameLayout.setOnFocusSearchListener { focused, direction -> val button1 = customTitleView.findViewById<TextView>(R.id.button_1) val button2 = customTitleView.findViewById<TextView>(R.id.button_2) if ((focused !== customTitleView && direction == View.FOCUS_UP) || (button2.hasFocus() && (direction == View.FOCUS_LEFT || direction == View.FOCUS_RIGHT))) { return@setOnFocusSearchListener button1 } if (button1.hasFocus() && (direction == View.FOCUS_LEFT || direction == View.FOCUS_RIGHT)) { return@setOnFocusSearchListener button2 } if ((button1.hasFocus() || button2.hasFocus()) && direction == View.FOCUS_DOWN) { return@setOnFocusSearchListener browseFrameLayout } browseFrameLayout } }
簡単にするためRTLサポートの処理は外しました。lb_vertical_grid_fragment
でidを確認していたのでBrowseFrameLayout
をfindしてから、独自のonFocusSearchListener
を設定すれば思い通りの動きにできます。ここではボタンが二つあり、それぞれにフォーカスがあるときの横移動は互いのViewへの移動になっています。リストを追加したり、より複雑な処理をするときもこれを拡張すれば良さそうです。ですが、タイトルの部分であまり複雑な処理を行う必要があるかは場合によると思います。下にRowやGridが配置されることになるので、リストの並び替え、フィルターなどくらいに留めて置くのが良いと思います。今回はonResume
に書いていますが、Viewが取得できる適切なタイミングならどこでも良いと思います。
以上です。