カタログ ブラウザを作成する

テレビで使用するメディアアプリでは、ユーザーが提供コンテンツをブラウズして選び、再生を開始できるようにする必要があります。このタイプのアプリのコンテンツ ブラウジングに関するユーザー エクスペリエンスは、シンプルで直観的、そして目を楽しませる魅力的なものである必要があります。

このレッスンでは、Leanback androidx ライブラリに含まれるクラスを使用して、アプリのメディア カタログから音楽や動画を閲覧するためのユーザー インターフェースを実装する方法について説明します。

注: ここに示す実装例では、サポートが終了した BrowseFragment クラスではなく、BrowseSupportFragment を使用しています。BrowseSupportFragmentAndroidXFragment クラスを拡張します。これにより、デバイスと Android のバージョン間で一貫した動作が保証されます。

アプリのメイン画面

図 1. Leanback のサンプルアプリ ブラウズ フラグメントによって、動画カタログのデータが表示されます。

メディア ブラウズ レイアウトを作成する

Leanback サポート ライブラリの BrowseSupportFragment クラスを使用すると、最小限のコードでブラウジング カテゴリのプライマリ レイアウトとメディア アイテムの行を作成できます。次の例は、BrowseSupportFragment オブジェクトを含むレイアウトを作成する方法を示しています。

    <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/main_frame"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <fragment
            android:name="com.example.android.tvleanback.ui.MainFragment"
            android:id="@+id/main_browse_fragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </FrameLayout>
    

次の例に示すように、このビューはアプリケーションのメイン アクティビティによって設定されます。

Kotlin

    class MainActivity : Activity() {

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.main)
        }
    ...
    

Java

    public class MainActivity extends Activity {
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.main);
        }
    ...
    

BrowseSupportFragment メソッドによってビューに動画データと UI 要素が設定され、アイコン、タイトル、カテゴリ ヘッダーが有効になっているかどうかなどのレイアウト パラメータが設定されます。

次の例に示すように、BrowseSupportFragment メソッドを実装するアプリケーションのサブクラスによって UI 要素に対するユーザー アクションのイベント リスナーも設定され、バックグラウンド マネージャーも準備されます。

Kotlin

    class MainFragment : BrowseSupportFragment(),
            LoaderManager.LoaderCallbacks<HashMap<String, List<Movie>>> {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)

            loadVideoData()
        }

        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)

            prepareBackgroundManager()
            setupUIElements()
            setupEventListeners()
        }
        ...

        private fun prepareBackgroundManager() {
            backgroundManager = BackgroundManager.getInstance(activity).apply {
                attach(activity?.window)
            }
            defaultBackground = resources.getDrawable(R.drawable.default_background)
            metrics = DisplayMetrics()
            activity?.windowManager?.defaultDisplay?.getMetrics(metrics)
        }

        private fun setupUIElements() {
            badgeDrawable = resources.getDrawable(R.drawable.videos_by_google_banner)
            // Badge, when set, takes precedent over title
            title = getString(R.string.browse_title)
            headersState = BrowseSupportFragment.HEADERS_ENABLED
            isHeadersTransitionOnBackEnabled = true
            // set headers background color
            brandColor = ContextCompat.getColor(requireContext(), R.color.fastlane_background)

            // set search icon color
            searchAffordanceColor = ContextCompat.getColor(requireContext(), R.color.search_opaque)
        }

        private fun loadVideoData() {
            VideoProvider.setContext(activity)
            videosUrl = getString(R.string.catalog_url)
            loaderManager.initLoader(0, null, this)
        }

        private fun setupEventListeners() {
            setOnSearchClickedListener {
                Intent(activity, SearchActivity::class.java).also { intent ->
                    startActivity(intent)
                }
            }

            onItemViewClickedListener = ItemViewClickedListener()
            onItemViewSelectedListener = ItemViewSelectedListener()
        }
        ...
    

Java

    public class MainFragment extends BrowseSupportFragment implements
            LoaderManager.LoaderCallbacks<HashMap<String, List<Movie>>> {
    }
    ...

        @Override
        public void onCreate(@Nullable Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);

            loadVideoData();
        }

        @Override
        public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
            super.onViewCreated(view, savedInstanceState);

            prepareBackgroundManager();
            setupUIElements();
            setupEventListeners();
        }
    ...

        private void prepareBackgroundManager() {
            backgroundManager = BackgroundManager.getInstance(getActivity());
            backgroundManager.attach(getActivity().getWindow());
            defaultBackground = getResources()
                .getDrawable(R.drawable.default_background);
            metrics = new DisplayMetrics();
            getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics);
        }

        private void setupUIElements() {
            setBadgeDrawable(getActivity().getResources()
                .getDrawable(R.drawable.videos_by_google_banner));
            // Badge, when set, takes precedent over title
            setTitle(getString(R.string.browse_title));
            setHeadersState(HEADERS_ENABLED);
            setHeadersTransitionOnBackEnabled(true);
            // set headers background color
            setBrandColor(ContextCompat.getColor(requireContext(), R.color.fastlane_background));
            // set search icon color
            setSearchAffordanceColor(ContextCompat.getColor(requireContext(), R.color.search_opaque));
        }

        private void loadVideoData() {
            VideoProvider.setContext(getActivity());
            videosUrl = getString(R.string.catalog_url);
            getLoaderManager().initLoader(0, null, this);
        }

        private void setupEventListeners() {
            setOnSearchClickedListener(new View.OnClickListener() {

                @Override
                public void onClick(View view) {
                    Intent intent = new Intent(getActivity(), SearchActivity.class);
                    startActivity(intent);
                }
            });

            setOnItemViewClickedListener(new ItemViewClickedListener());
            setOnItemViewSelectedListener(new ItemViewSelectedListener());
        }
    ...
    

UI 要素を設定する

上記のサンプルでは、メディア カタログ ブラウザのスタイルを指定するためにプライベート メソッド setupUIElements() によって複数の BrowseSupportFragment メソッドが呼び出されます。

  • setBadgeDrawable() は図 1 と図 2 に示すように、指定されたドローアブル リソースをブラウズ フラグメントの右上隅に配置します。setTitle() も呼び出された場合、このメソッドはタイトル文字列をドローアブル リソースに置き換えます。ドローアブル リソースの高さは 52 dps である必要があります。
  • setTitle() は、setBadgeDrawable() が呼び出されない限り、タイトル文字列をブラウズ フラグメントの右上隅に設定します。
  • setHeadersState()setHeadersTransitionOnBackEnabled() は、ヘッダーを非表示または無効にします。 詳しくは、ヘッダーを非表示または無効にするをご覧ください。
  • setBrandColor() は、ブラウズ フラグメント内の UI 要素の背景色(特にヘッダー セクションの背景の色)を指定された色の値を使用して設定します。
  • setSearchAffordanceColor() は、検索アイコンの色を指定された色の値を使用して設定します。図 1 と図 2 に示すように、検索アイコンはブラウズ フラグメントの左上隅に表示されます。

ヘッダービューをカスタマイズする

図 1 に示すブラウズ フラグメントでは、動画カテゴリ名(行ヘッダー)が左ペインに表示されます。テキストビューには、これらのカテゴリ名が動画データベースから表示されます。より複雑なレイアウトでその他のビューが含まれるようにヘッダーをカスタマイズできます。ここでは、図 2 に示すようにカテゴリ名の横にアイコンを表示する画像ビューを含める方法について説明します。

アプリのメイン画面

図 2. ブラウズ フラグメント内の行ヘッダー。アイコンとテキストラベルの両方があります。

行ヘッダーのレイアウトは次のように定義されます。

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/header_icon"
            android:layout_width="32dp"
            android:layout_height="32dp" />
        <TextView
            android:id="@+id/header_label"
            android:layout_marginTop="6dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

    </LinearLayout>
    

Presenter を使用して、ビューホルダーを作成、バインド、アンバインドする抽象メソッドを実装します。次の例は、2 つのビュー ImageView および TextView を含むビューホルダーをバインドする方法を示しています。

Kotlin

    class IconHeaderItemPresenter : Presenter() {

        override fun onCreateViewHolder(viewGroup: ViewGroup): Presenter.ViewHolder {
            val view = LayoutInflater.from(viewGroup.context).run {
                inflate(R.layout.icon_header_item, null)
            }

            return Presenter.ViewHolder(view)
        }

        override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, o: Any) {
            val headerItem = (o as ListRow).headerItem
            val rootView = viewHolder.view

            rootView.findViewById<ImageView>(R.id.header_icon).apply {
                rootView.resources.getDrawable(R.drawable.ic_action_video, null).also { icon ->
                    setImageDrawable(icon)
                }
            }

            rootView.findViewById<TextView>(R.id.header_label).apply {
                text = headerItem.name
            }
        }

        override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) {
            // no op
        }
    }
    

Java

    public class IconHeaderItemPresenter extends Presenter {
        @Override
        public ViewHolder onCreateViewHolder(ViewGroup viewGroup) {
            LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext());

            View view = inflater.inflate(R.layout.icon_header_item, null);

            return new ViewHolder(view);
        }

        @Override
        public void onBindViewHolder(ViewHolder viewHolder, Object o) {
            HeaderItem headerItem = ((ListRow) o).getHeaderItem();
            View rootView = viewHolder.view;

            ImageView iconView = (ImageView) rootView.findViewById(R.id.header_icon);
            Drawable icon = rootView.getResources().getDrawable(R.drawable.ic_action_video, null);
            iconView.setImageDrawable(icon);

            TextView label = (TextView) rootView.findViewById(R.id.header_label);
            label.setText(headerItem.getName());
        }

        @Override
        public void onUnbindViewHolder(ViewHolder viewHolder) {
        // no op
        }
    }
    

ヘッダーは、D-pad を使用してスクロールできるようフォーカス可能でなければなりません。方法には 2 つあります。

  • onBindViewHolder() でビューをフォーカス可能に設定します。

    Kotlin

        override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, o: Any) {
            val headerItem = (o as ListRow).headerItem
            val rootView = viewHolder.view
    
            rootView.focusable = View.FOCUSABLE
            //...
        }
        

    Java

        @Override
        public void onBindViewHolder(ViewHolder viewHolder, Object o) {
            HeaderItem headerItem = ((ListRow) o).getHeaderItem();
            View rootView = viewHolder.view;
            rootView.setFocusable(View.FOCUSABLE) // Allows the D-Pad to navigate to this header item
            //...
        }
        
  • レイアウトをフォーカス可能に設定します。
    <?xml version="1.0" encoding="utf-8"?>
        <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
           ...
           android:focusable="true">

最後に、カタログ ブラウザを表示する BrowseSupportFragment 実装で、次の例に示すように setHeaderPresenterSelector() メソッドを使用して行ヘッダーのプレゼンターを設定します。

Kotlin

    setHeaderPresenterSelector(object : PresenterSelector() {
        override fun getPresenter(o: Any): Presenter {
            return IconHeaderItemPresenter()
        }
    })
    

Java

    setHeaderPresenterSelector(new PresenterSelector() {
        @Override
        public Presenter getPresenter(Object o) {
            return new IconHeaderItemPresenter();
        }
    });
    

例の詳細については、Android TV GitHub リポジトリにある Android Leanback サンプルアプリをご覧ください。

ヘッダーを非表示または無効にする

スクロール可能なリストが必要なほどカテゴリが多くない場合など、行ヘッダーを表示する必要がない場合は、フラグメントの onActivityCreated() メソッド中に BrowseSupportFragment.setHeadersState() メソッドを呼び出して、行ヘッダーを非表示または無効にします。setHeadersState() メソッドは、次の定数のいずれかをパラメータとして与えられ、ブラウズ フラグメント内のヘッダーの初期状態を設定します。

  • HEADERS_ENABLED - ブラウズ フラグメント アクティビティが作成されると、ヘッダーはデフォルトで有効になり、表示されます。ヘッダーはこのページの図 1 と図 2 のように表示されます。
  • HEADERS_HIDDEN - ブラウズ フラグメント アクティビティが作成されると、ヘッダーはデフォルトで有効になり、非表示になります。カードビューを提供する図 1 のように、画面のヘッダー セクションは折りたたまれます。ユーザーは、折りたたまれたヘッダー セクションを選択して展開できます。
  • HEADERS_DISABLED - ブラウズ フラグメント アクティビティが作成されると、ヘッダーはデフォルトで無効になり、表示されません。

HEADERS_ENABLED または HEADERS_HIDDEN が設定された場合、setHeadersTransitionOnBackEnabled() を呼び出して、行で選択されたコンテンツ アイテムから行ヘッダーに戻る動作をサポートできます。これはデフォルトで有効ですが(メソッドを呼び出さない場合)、戻るという動作を自分で処理する場合は、値 falsesetHeadersTransitionOnBackEnabled() に渡し、独自のバックスタック処理を実装する必要があります。

メディアリストを表示する

BrowseSupportFragment クラスでは、アダプターとプレゼンターを使用して、ブラウズ可能なメディア コンテンツのカテゴリやメディア カタログのメディア アイテムを定義したり表示したりできます。アダプターを使用すると、メディア カタログ情報が含まれているローカルやオンラインのデータソースに接続できます。 アダプターはプレゼンターを使用してビューを作成し、アイテムを画面に表示するためにそれらのビューにデータをバインドします。

次のコード例は、文字列データを表示する際の Presenter の実装を示しています。

Kotlin

    private const val TAG = "StringPresenter"

    class StringPresenter : Presenter() {

        override fun onCreateViewHolder(parent: ViewGroup): Presenter.ViewHolder {
            val textView = TextView(parent.context).apply {
                isFocusable = true
                isFocusableInTouchMode = true
                background = parent.resources.getDrawable(R.drawable.text_bg)
            }
            return Presenter.ViewHolder(textView)
        }

        override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) {
            (viewHolder.view as TextView).text = item.toString()
        }

        override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) {
            // no op
        }
    }
    

Java

    public class StringPresenter extends Presenter {
        private static final String TAG = "StringPresenter";

        public ViewHolder onCreateViewHolder(ViewGroup parent) {
            TextView textView = new TextView(parent.getContext());
            textView.setFocusable(true);
            textView.setFocusableInTouchMode(true);
            textView.setBackground(
                    parent.getResources().getDrawable(R.drawable.text_bg));
            return new ViewHolder(textView);
        }

        public void onBindViewHolder(ViewHolder viewHolder, Object item) {
            ((TextView) viewHolder.view).setText(item.toString());
        }

        public void onUnbindViewHolder(ViewHolder viewHolder) {
            // no op
        }
    }
    

メディア アイテムのプレゼンター クラスを構築すると、アダプターをビルドしたり、BrowseSupportFragment にアタッチしたりして、ユーザーのブラウジング用に画面上にそれらのアイテムを表示させることができます。次のサンプルコードでは、1 つ前のコード例に示したように、StringPresenter クラスを使用してカテゴリとそのカテゴリ内のアイテムを表示するためのアダプターの構築方法を示しています。

Kotlin

    private const val NUM_ROWS = 4
    ...
    private lateinit var rowsAdapter: ArrayObjectAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        buildRowsAdapter()
    }

    private fun buildRowsAdapter() {
        rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
        for (i in 0 until NUM_ROWS) {
            val listRowAdapter = ArrayObjectAdapter(StringPresenter()).apply {
                add("Media Item 1")
                add("Media Item 2")
                add("Media Item 3")
            }
            HeaderItem(i.toLong(), "Category $i").also { header ->
                rowsAdapter.add(ListRow(header, listRowAdapter))
            }
        }
        browseSupportFragment.adapter = rowsAdapter
    }
    

Java

    private ArrayObjectAdapter rowsAdapter;
    private static final int NUM_ROWS = 4;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...

        buildRowsAdapter();
    }

    private void buildRowsAdapter() {
        rowsAdapter = new ArrayObjectAdapter(new ListRowPresenter());

        for (int i = 0; i < NUM_ROWS; ++i) {
            ArrayObjectAdapter listRowAdapter = new ArrayObjectAdapter(
                    new StringPresenter());
            listRowAdapter.add("Media Item 1");
            listRowAdapter.add("Media Item 2");
            listRowAdapter.add("Media Item 3");
            HeaderItem header = new HeaderItem(i, "Category " + i);
            rowsAdapter.add(new ListRow(header, listRowAdapter));
        }

        browseSupportFragment.setAdapter(rowsAdapter);
    }
    

この例では、アダプターの静的実装を示しています。一般的なメディア ブラウジング アプリは、オンライン データベースやウェブサービスのデータを使用します。ウェブから取得したデータを使用するブラウジング アプリの例については、Android TV GitHub リポジトリにある Android Leanback サンプルアプリをご覧ください。

背景を切り替える

テレビで使用されるメディア ブラウジング アプリを目立たせるために、ユーザーがコンテンツをブラウジングしている間に背景イメージを切り替えられます。これにより、アプリの使用がより動きのある楽しいものになります。

Leanback サポート ライブラリは、テレビアプリのアクティビティの背景を変更するための BackgroundManagerBackgroundManager クラスを提供しています。次の例では、テレビアプリのアクティビティ内の背景をアップデートする簡単なメソッドの作成方法を示しています。

Kotlin

    protected fun updateBackground(drawable: Drawable) {
        BackgroundManager.getInstance(this).drawable = drawable
    }
    

Java

    protected void updateBackground(Drawable drawable) {
        BackgroundManager.getInstance(this).setDrawable(drawable);
    }
    

既存のほとんどのメディア ブラウズ アプリでは、ユーザーがメディアリストをスクロールするのに合わせて自動的に背景が切り替えられます。これを行うには選択リスナーを設定して、ユーザーの現在の選択に基づいて自動的に背景を変更できるようにします。次の例は、OnItemViewSelectedListener クラスをセットアップして、選択イベントの判別後に背景を変更する方法を示しています。

Kotlin

    protected fun clearBackground() {
        BackgroundManager.getInstance(this).drawable = defaultBackground
    }

    protected fun getDefaultItemViewSelectedListener(): OnItemViewSelectedListener =
            OnItemViewSelectedListener { _, item, _, _ ->
                if (item is Movie) {
                    item.getBackdropDrawable().also { background ->
                        updateBackground(background)
                    }
                } else {
                    clearBackground()
                }
            }
    

Java

    protected void clearBackground() {
        BackgroundManager.getInstance(this).setDrawable(defaultBackground);
    }

    protected OnItemViewSelectedListener getDefaultItemViewSelectedListener() {
        return new OnItemViewSelectedListener() {
            @Override
            public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item,
                    RowPresenter.ViewHolder rowViewHolder, Row row) {
                if (item instanceof Movie ) {
                    Drawable background = ((Movie)item).getBackdropDrawable();
                    updateBackground(background);
                } else {
                    clearBackground();
                }
            }
        };
    }
    

注: 上記の実装例は、わかりやすくするために実際よりも単純化されています。実際にアプリでこの機能を作成する際は、パフォーマンス向上のために、別のスレッドで背景の変更アクションを実行するようにしてください。また、ユーザーがアイテムをスクロールする動作に合わせて背景を切り替える場合には、ユーザーが 1 つのアイテムに落ち着くまで背景イメージの変更を遅らせる時間を追加することも検討してください。これにより、背景イメージの切り替えが頻繁に発生しないようにできます。