道産子エンジニア

悲観主義は気分に属し、楽観主義は意志に属する

【AndroidTV】android.support.v17.leanbackの中を読む ~part2. TitleView編~

前回に引き続き、AndroidTVのleanbackを読んでいきます。 今回はAndroidTVのアプリ開発をするといつも必要になる「TitleView」のカスタマイズについてです。

これを読んでいる方は、AndroidTVで下のようなレイアウトを実現できるでしょうか。

youtu.be

もしできるのであれば、今回の内容はもう理解されていると思いますので、あまり役に立たないかもしれません。笑

普段Androidアプリの開発をしていて、AndroidTVアプリの開発をしたことがない人には「こんな簡単なこともすぐにできないのか!」と知っていただきたくて載せました。 多分、この辺でつまづいた人も少なくないと思います。「そうそう、それがやりたかったんだよ!」という方はぜひ一読していただけると嬉しいです。読み終わる頃には仕組みが理解できて、すぐに実装できるようになっています。

少しでもAndroidTVで開発するエンジニアの助けになれたら幸いです。

さて、本題に入ります。

TitleViewとは

TitleViewは BrandedFragment が持つタイトルを表示するためのViewです。アプリや画面の情報を載せておけるViewの一つです。 なので今回の内容はBrandedFragmentを継承したクラスには適応可能です。前回載せたクラス群で確認しましょう。

Fragment
 ...
 ├─BrandedFragment(BrandedSupportFragment)
 │ ├─ErrorFragment(ErrorSupportFragment)
 │ └─BaseFragment(BaseSupportFragment)
 │   ├─BrowseFragment(BrowseSupportFragment)
 │   ├─DetailsFragment(DetailsFragmentBackgroundController)
 │   └─VerticalGridFragment(VerticalGridSupportFragment)
 ...

特によく使うコンポーネントの例をあげればBrowseFragmentVerticalGridFragmentでしょう。それぞれにおけるTitleViewは赤枠で囲った部分になります。

BrowseFragment

f:id:yuichi31:20171216105836p:plain

VerticalGridFragment

f:id:yuichi31:20171216105851p:plain

なお、バッジを設定することもできて、バッジを設定するとタイトルの文字は隠されます。

バッジを設定したとき

f:id:yuichi31:20171216110255p:plain

デフォルトの機能で開発者が変更可能なのは

  • タイトルの文字の変更
  • バッジの設定
  • 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.ProvidergetTitleViewAdapterメソッドでそれを返す

今回はSearchOrbを消していると書きましたが、

 override fun getSearchAffordanceView(): View? = null

の部分を変更すればカスタマイズ可能です。次はCustomTitleViewBrandedFragmentに設定しましょう。

onInflateTitleViewCustomTitleViewを渡す

今回は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のフォーカスを制御していそうです。あと少しですね。BrowseFrameLayoutsetOnFocusSearchListenerを読むと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にフォーカスが合うような実装になっています。mSceneRootTitleHelper初期化のときに設定する、TitleViewを持つ親のViewGroupです。この処理を変更すれば、自分が思うようなフォーカス制御ができそうです。

BrowseFrameLayoutCustomTitleViewのフォーカスの動きを定義した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が取得できる適切なタイミングならどこでも良いと思います。

以上です。