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

テレビで動作するメディアアプリは、ユーザーが提供内容をブラウジングし、選択を行い、コンテンツの再生を開始できるようにする必要があります。コンテンツ ブラウジング エクスペリエンスは、シンプルで直感的、視覚的に魅力的で魅力的なものである必要があります。

このガイドでは、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 要素が設定され、レイアウト パラメータ(アイコン、タイトル、カテゴリ ヘッダーが有効かどうかなど)が設定されます。

UI 要素の設定について詳しくは、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 header 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 header 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 dp にする必要があります。
  • 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 を使用して、ビューホルダーの作成、バインド、バインド解除を行う抽象メソッドを実装します。次の例は、ImageViewTextView の 2 つのビューにビューホルダーをバインドする方法を示しています。

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: ブラウズ フラグメント アクティビティが作成されると、ヘッダーはデフォルトで有効になり、非表示になります。カードビューを提供するに示すように、画面のヘッダー セクションが折りたたまれています。ユーザーは、折りたたまれたヘッダー セクションを選択して展開できます。
  • 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 サポート ライブラリには、TV アプリのアクティビティの背景を変更するための BackgroundManager クラスが用意されています。次の例は、シンプルなメソッドを作成して TV アプリのアクティビティ内で背景を更新する方法を示しています。

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();
            }
        }
    };
}

注: 上記の実装は、説明を目的とした単純な例です。独自のアプリでこの関数を作成する場合は、パフォーマンス向上のために、別のスレッドでバックグラウンド更新アクションを実行します。また、ユーザーがアイテムをスクロールする操作に応じて背景を更新する予定がある場合は、ユーザーがアイテムに落ち着くまで背景画像の更新を遅らせる時間を追加します。これにより、背景画像の過剰な更新を回避できます。