创建目录浏览器

在电视上运行的媒体应用需要允许用户浏览其提供的内容、进行选择,然后开始播放内容。内容浏览体验必须简单直观,而且视觉上要赏心悦目、引人入胜。

本指南讨论了如何使用 Leanback androidx 库提供的类来实现一个界面,该界面可让用户浏览应用的媒体目录中包含的音乐或视频。

注意:此处显示的实现示例使用的是 BrowseSupportFragment,而不是已废弃的 BrowseFragment 类。BrowseSupportFragment 扩展了 AndroidX Fragment 类,有助于确保在设备和 Android 版本之间实现一致的行为。

应用主屏幕

图 1. Leanback 示例应用的浏览 fragment 显示视频目录数据。

创建媒体浏览布局

通过 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>

应用的主 Activity 用于设置此视图,如下例所示:

class MainActivity : Activity() {

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

BrowseSupportFragment 方法会使用视频数据和界面元素填充视图,并设置图标和标题等布局参数,以及是否启用类别标题。

如需详细了解如何设置界面元素,请参阅设置界面元素部分。如需详细了解如何隐藏标题,请参阅隐藏或停用标题部分。

实现 BrowseSupportFragment 方法的应用子类还会设置事件监听器来监听界面元素上的用户操作,并准备后台管理器,如以下示例所示:

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

设置界面元素

在前面的示例中,私有方法 setupUIElements() 调用了几个 BrowseSupportFragment 方法来设置媒体目录浏览器的样式:

  • setBadgeDrawable() 会将指定的可绘制资源置于浏览 Fragment 的右上角,如图 1 和图 2 所示。如果还调用了 setTitle(),此方法会将标题字符串替换为可绘制资源。可绘制资源的高度必须为 52 dp。
  • setTitle() 用于设置浏览 Fragment 右上角的标题字符串,除非调用了 setBadgeDrawable()
  • setHeadersState()setHeadersTransitionOnBackEnabled() 用于隐藏或停用标题。 如需了解详情,请参阅隐藏或停用标题部分。
  • setBrandColor() 用于将浏览 Fragment 中界面元素的背景颜色(具体来说就是标题部分的背景颜色)设置为指定的颜色值。
  • setSearchAffordanceColor() 用于将搜索图标的颜色设为指定的颜色值。搜索图标显示在浏览 Fragment 的左上角,如图 1 和图 2 所示。

自定义标题视图

图 1 中显示的浏览 fragment 以文本视图的形式显示视频类别名称,即视频数据库中的行标题。您还可以自定义标题,在更复杂的布局中添加更多视图。下面几部分将介绍如何添加在类别名称旁边显示图标的图片视图,如图 2 所示。

应用主屏幕

图 2. 浏览 fragment 中的行标题,同时具有图标和文本标签。

行标题的布局定义如下:

<?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 并实现抽象方法,以创建、绑定和取消绑定 ViewHolder。以下示例展示了如何将 viewholder 与两个视图(一个 ImageViewTextView)绑定。

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
   
}
}
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
   
}
}

您的头文件必须可聚焦,以便使用方向键进行滚动浏览。您可以通过以下两种方式解决此问题:

  • onBindViewHolder() 中将视图设置为可聚焦:
    override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, o: Any) {
       
    val headerItem = (o as ListRow).headerItem
       
    val rootView = viewHolder.view

        rootView
    .focusable = View.FOCUSABLE
       
    // ...
    }
    @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() 方法为行标题设置 Presenter,如下例所示。

setHeaderPresenterSelector(object : PresenterSelector() {
   
override fun getPresenter(o: Any): Presenter {
       
return IconHeaderItemPresenter()
   
}
})
setHeaderPresenterSelector(new PresenterSelector() {
   
@Override
   
public Presenter getPresenter(Object o) {
       
return new IconHeaderItemPresenter();
   
}
});

如需查看完整示例,请参阅 Leanback 示例应用

隐藏或停用标题

有时,您并不需要显示行标题,例如,没有足够的类别来要求显示可滚动列表时。在 fragment 的 onActivityCreated() 方法期间调用 BrowseSupportFragment.setHeadersState() 方法可隐藏或停用行标题。setHeadersState() 方法可设置浏览 fragment 中标题的初始状态,前提是给定以下某个常量作为参数:

  • HEADERS_ENABLED:创建浏览 Fragment Activity 后,默认情况下会启用并显示标题。标题如本页图 1 和图 2 中所示。
  • HEADERS_HIDDEN:创建浏览 fragment activity 后,默认情况下会启用并隐藏标题。 屏幕的标题部分处于收起状态,如提供卡片视图中的 数字所示。用户可以选择收起的标题部分以将其展开。
  • HEADERS_DISABLED:创建浏览 fragment activity 后,标题默认处于停用状态,并且一律不显示。

如果设置了 HEADERS_ENABLEDHEADERS_HIDDEN,您可以调用 setHeadersTransitionOnBackEnabled(),以支持从行中所选内容项返回到行标题。如果您不调用该方法,则默认处于启用状态。如需自行处理返回操作,请将 false 传递给 setHeadersTransitionOnBackEnabled(),并实现您自己的返回堆栈处理。

显示媒体列表

借助 BrowseSupportFragment 类,您可以使用 Adapter 和 Presenter 定义和显示来自媒体目录的可浏览媒体内容类别和媒体内容。借助适配器,您可以连接到包含媒体目录信息的本地或在线数据源。Adapter 使用 Presenter 来创建视图并将数据绑定到这些视图,以便在屏幕上显示内容。

以下示例代码演示了用于显示字符串数据的 Presenter 实现:

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
   
}
}
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
   
}
}

为媒体项构建 Presenter 类后,您可以构建 Adapter 并将其附加到 BrowseSupportFragment,以在屏幕上显示这些内容以供用户浏览。以下示例代码展示了如何构建 Adapter 来利用上一代码示例中所示的 StringPresenter 类来显示类别以及这些类别中的项目:

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

此示例展示了 Adapter 的一个静态实现。典型的媒体浏览应用使用的是来自在线数据库或网络服务的数据。如需查看使用从网络检索的数据的浏览应用示例,请参阅 Leanback 示例应用

更新背景

如需增加电视上的媒体浏览应用的视觉吸引力,您可以在用户浏览内容时更新背景图片。此方法可使用户与应用的互动更加赏心悦目。

Leanback 支持库提供了一个 BackgroundManager 类,用于更改 TV 应用 Activity 的背景。以下示例展示了如何创建一个简单的方法来更新 TV 应用 Activity 中的背景:

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

许多媒体浏览应用会在用户浏览媒体列表时自动更新背景。为此,您可以设置一个选择监听器,以根据用户的当前选择自动更新背景。以下示例展示了如何设置一个 OnItemViewSelectedListener 类来捕获选择事件并更新背景:

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

注意:前面的实现是一个简单的示例,仅作说明之用。在您自己的应用中创建此函数时,请在单独的线程中运行后台更新操作,以提高性能。此外,如果您计划在用户滚动浏览商品时更新背景,请添加一个时间来延迟背景图片更新,直到用户选择某个商品。此方法可以避免背景图片更新过于频繁。