道産子エンジニア

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

【AndroidTV】android.support.v17.leanbackの中を読む ~part1. FocusHighlight編~

CyberAgentAdventCalendar4日目を担当するまえかわです。 前回は山本孔明さんのAnsible2.4でネットワークプログラマビリティな運用を考えるでした。

--

現在はAbemaTVで開発を行なっており、AndroidTVデバイスの開発を行なって一ヶ月ほど経ちました。AndroidTVの開発では基本的にandroid.support.v17.leanbackのコンポーネントを使用していくことになります。テレビ用のアプリを作るにはleanbackライブラリのことを理解するのが重要です。今回はleanbackの中でも重要なpackage内のクラスをざっと紹介し、さらにドキュメントでは詳しく紹介されてないFocusHighlightに関するAPIの内部実装を読んだので紹介します。

ここではAndroidTVにおけるUIデザインの思想やデザインガイドについては紹介しません。また、公式のドキュメントに書いてあることを詳しくは載せません。日本語ドキュメントなどコンポーネントの紹介記事は充実しているので、詳しく紹介していない部分については他の記事を参照してください。

leanback概要

まずは重要なコンポーネント群について簡単に紹介します。 f:id:yuichi31:20171203224048p:plain この図はAndroidTVでの開発を行うとき、何度も参照することになるandroidtv-leanback | Google Samples on GithubのREADMEから持ってきました。AndroidTVらしい画面を作るのに欠かせないこれらのコンポーネントは、多くの他の記事などでもよく解説されているためここでは詳しく紹介しません。一部を除くこれらのコンポーネントが含まれたパッケージが、android.support.v17.leanback.appです。継承関係などを見ていきましょう。

android.support.v17.leanback.app内のクラス

Fragment
 ├─BackgroundFragment
 ├─BrandedFragment(BrandedSupportFragment)
 │ ├─ErrorFragment(ErrorSupportFragment)
 │ └─BaseFragment(BaseSupportFragment)
 │   ├─BrowseFragment(BrowseSupportFragment)
 │   ├─DetailsFragment(DetailsFragmentBackgroundController)
 │   └─VerticalGridFragment(VerticalGridSupportFragment)
 ├─BaseRowFragment(BaseRowSupportFragment)
 │ ├─HeadersFragment(HeadersSupportFragment)
 │ └─RowsFragment(RowsSupportFragment)
 ├─OnboardingFragment(OnboardingSupportFragment)
 ├─GuidedStepFragment(GuidedStepSupportFragment)
 ├─PlaybackFragment(PlaybackSupportFragment)
 │ └─VideoFragment(VideoSupportFragment)
 └─SearchFragment(SearchSupportFragment)

- BackgroundManager
- FragmentUtil
- ListRowDataAdapter
- ProgressBarManager
- PermissionHelper
- PlaybackFragmentGlueHost
- VideoFragmentGlueHost

たくさんのコンポーネントがあるので親になるクラスから読み解いていくのがオススメです。android.support.v17.leanback.appの中を確認することで、HeaderとRowを使った画面の組み方なども見えてきます。また、ProgressBarManagerBackgroundManagerといったコンテンツ表示に関する共通処理を用意していてくれているのもleanbackならではです。

PlaybackFragmentVideoFragmentは動画再生に使うものですが、再生のコントローラーをGlueと呼び以下のような種類があります。

https://developer.android.com/training/tv/images/glue_side_by_side.png

Presenter

画面関連のクラスの全体像がわかりましたが、他にも今回紹介する内容の理解に必要なPresenterを紹介しておきます。

leanbackでは簡単に言うとAndroidアプリにおけるRecyclerviewGridview(Horizontal or Vertical)として扱い、AdapterPresenterとして扱います。MVPの思想でAPIを設計していて、そこからPresenterという名前が来ています。Presenterに関する詳しい解説は、同じくAbemaチームのogaclejapanさんのDroidKaigi2017での資料: your app nameで詳しく紹介されているので参照してください。

Presenterは今回の話以外の細かい部分を省くと以下の二種類が重要になります。

  • 縦方向のGridに対応するVerticalGridPresenter
  • 横方向のRowに対応するListRowPresenter

この二つのクラスが重要なZoomとDim機能をもっているので、次節で紹介します。

Presenterには紹介したい内容がたくさんあるのですが、今回は絞って書いています。個人的にはRecyclerViewのアダプタに対して、ObjectAdapter内のアイテムを受け渡している ItemBridgeAdapterクラスを読むのがおすすめです。ItemBridgeAdapterはこのあとも登場します。ItemBridgeAdapterのコメントには

Public to allow use by third party Presenters.

と記述されているので、Presenterを自作する際にも使えます。

ZoomとDim = FocusHighlight

他のAndroidTVに関する発表や記事でも紹介されていますが、leanbackの特徴的な動きにフォーカス制御が挙げられます。 テレビとリモコンつかうAndroidTVでは、コンテンツを 「より画面の中央に見やすく表示する」 ことを強く意識して設計されているようです。 そのためには選択したコンテンツを強調する必要があります。それらを実現するためにleanbackでは

  • focusしているカードを大きく表示する
  • focusしていないカードを目立たなくする

というアプローチを取っており、これらを合わせて FocusHighlight と呼び、それぞれZoomとDimという名前で実装されています。

FocusHighlightHelper

FocusHighlightを各カードに対して実行するのがFocusHighlightHelperであり、ZoomとDimの設定をVerticalGridPresenterListRowPresenterのコンストラクタで指定します。

  • ListRowPresenter
public ListRowPresenter(int focusZoomFactor, boolean useFocusDimmer) {
        if (!FocusHighlightHelper.isValidZoomIndex(focusZoomFactor)) {
            throw new IllegalArgumentException("Unhandled zoom factor");
        }
        mFocusZoomFactor = focusZoomFactor;
        mUseFocusDimmer = useFocusDimmer;
}
  • VerticalGridPresenter
public VerticalGridPresenter(int focusZoomFactor, boolean useFocusDimmer) {
        mFocusZoomFactor = focusZoomFactor;
        mUseFocusDimmer = useFocusDimmer;
}

focusZoomFactoruseFocusDimmerを指定しない場合は、それぞれ次の値がデフォルトとなります。

focusZoomFactor useFocusDimmer
ListRowPresenter FocusHighlight.ZOOM_FACTOR_MEDIUM false
VerticalGridPresenter FocusHighlight.ZOOM_FACTOR_LARGE true

この設定をFocusHighlightHelperのイニシャライズ時に指定しています。

  • ListRowPresenter
FocusHighlightHelper.setupBrowseItemFocusHighlight(rowViewHolder.mItemBridgeAdapter,
                mFocusZoomFactor, mUseFocusDimmer);
  • VerticalGridPresenter
FocusHighlightHelper.setupBrowseItemFocusHighlight(vh.mItemBridgeAdapter,
                mFocusZoomFactor, mUseFocusDimmer);

このとき渡しているmFocusZoomFactoermUseFocusDimmerでZoomとDimを調整します。これらを各Presenterの フォーカスが当たった時にZoomとDimといった機能でコンテンツを強調表示しています。ここで先ほど紹介したItemBridgeAdapterが登場します。

Zoomについて

Zoomサイズの調整

android.support.v17.leanback.widget パッケージ内にあるFocusHighlightというインターフェース内にZoomFactorが定義されており、カードアイテムのZoomサイズを調整できます。 それぞれに対応するzoomサイズを画像にしました。

focus size image
ZOOM_FACTOR_LARGE(118%) f:id:yuichi31:20171203144738p:plain:w300
ZOOM_FACTOR_MEDIUM(114%) f:id:yuichi31:20171203144751p:plain:w300
ZOOM_FACTOR_SMALL(110%) f:id:yuichi31:20171203144806p:plain:w300
ZOOM_FACTOR_XSMALL(106%) f:id:yuichi31:20171203144829p:plain:w300
ZOOM_FACTOR_NONE f:id:yuichi31:20171203144852p:plain:w300

かなり細かいサイズの違いなのでわかりにくいですが、カード内の情報によってはかぶりすぎてしまわないように細かい調整が可能になっています。

FocusHighlightHelper内での実装

Presenterで呼び出されていた FocusHighlightHelper#setupBrowseItemFocusHighlight はこのようになっています。

public static void setupBrowseItemFocusHighlight(ItemBridgeAdapter adapter, int zoomIndex, boolean useDimmer) {
        adapter.setFocusHighlight(new BrowseItemFocusHighlight(zoomIndex, useDimmer));
}

BrowseItemFocusHighlightonItemFocusedを呼び出すインターフェースを実装しており、ItemBridgeAdapter#onFocusで呼び出され、FocusHighlightHelperのインナークラスFocusAnimatorsetFocusLevelを呼び出すことでアニメーションを行なっています。

void setFocusLevel(float level) {
        mFocusLevel = level;
        float scale = 1f + mScaleDiff * level;
        mView.setScaleX(scale);
        mView.setScaleY(scale);
        if (mWrapper != null) {
            mWrapper.setShadowFocusLevel(level);
        } else {
            ShadowOverlayHelper.setNoneWrapperShadowFocusLevel(mView, level);
        }
        if (mDimmer != null) {
            mDimmer.setActiveLevel(level);
            int color = mDimmer.getPaint().getColor();
            if (mWrapper != null) {
                mWrapper.setOverlayColor(color);
            } else {
                ShadowOverlayHelper.setNoneWrapperOverlayColor(mView, color);
            }
        }
}

Dimについて

Dimとは言葉の通りカードを暗くする機能のことであり、こちらもZoomと同じくFocusHighlightHelperがもつColorOverlayDimmerというクラスが行なっています。 Presenterのコンストラクタに渡したmUseFocusDimmerのフラグによってスイッチング可能です。(Zoomをnoneにしています)

mUseFocusDimmer image
true f:id:yuichi31:20171204125823p:plain:w400
false f:id:yuichi31:20171204125841p:plain:w400

ColorOverlayDimmerのコンストラクタには三つの引数があります。

  • dimColor
  • activeLevel
  • dimmedLevel

dimColorはオーバーレイする色のことで、activeとdimmedLevelはそれぞれViewがactiveかどうでないかの状態を渡しているものです。

Zoomの時に紹介したFocusAnimatorsetFocusLevelで同じように呼び出されている mDimmerColorOverlayDimmerであり、 setActiveLevelの実装は以下です。

  • ColorOverlayDimmer
 /**
     * Sets the active level of the dimmer. Updates the alpha value based on the
     * level.
     *
     * @param level A float between 0 (fully dim) and 1 (fully active).
     */
public void setActiveLevel(float level) {
        mAlphaFloat = (mDimmedLevel + level * (mActiveLevel - mDimmedLevel));
        mAlpha = (int) (255 * mAlphaFloat);
        mPaint.setAlpha(mAlpha);
}

スクロールのアニメーションに合わせて、active <-> dimのレベルを渡しdimColorの濃淡を表現しているようです。


以上です。FocusHighlightの仕組みがわかってきたので、これを応用して自作のUIを作っていけそうです。 今回は長くなるので紹介しなかったwidgetパッケージについても別な記事で紹介する予定です。また、来年はDroidKiagi2018でAndroidTVに関する内容で発表を行います。 余裕があれば技術書典にAndroidTVに関する内容で自分で書いてみようと思っています。