创建目录浏览器

使用 Compose 构建更佳体验
使用 Jetpack Compose for Android TV OS 创建美观的 UI,代码量最少。

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

本指南讨论了如何使用 androidx.leanback 库 提供的类来实现用于浏览应用媒体目录中的音乐或视频的用户界面。

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

App main screen

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

创建媒体浏览布局

Leanback UI 工具包中的 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);
    }
...

The BrowseSupportFragment methods populate the view with the video data and UI elements and set layout parameters such as the icon and title and whether category headers are enabled.

有关设置 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 所示。

App main screen

图 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并实现抽象方法来创建、绑定和解除绑定视图持有者。以下示例显示了如何使用两个视图(一个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 在其中滚动。有两种方法可以管理此操作

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

有关完整的示例,请参阅 Leanback 示例应用

隐藏或禁用标题

有时您不希望显示行标题,例如当类别不足以需要可滚动列表时。在片段的onActivityCreated()方法期间调用BrowseSupportFragment.setHeadersState()方法以隐藏或禁用行标题。setHeadersState()方法根据以下常量之一作为参数设置浏览片段中标题的初始状态

  • HEADERS_ENABLED:创建浏览片段活动时,默认情况下标题处于启用状态并显示。标题显示在本页的图 1 和图 2 中。
  • HEADERS_HIDDEN:创建浏览片段活动时,默认情况下标题处于启用状态但隐藏。屏幕的标题部分已折叠,如 提供卡片视图中的一个图所示。用户可以选择折叠的标题部分以展开它。
  • HEADERS_DISABLED:创建浏览片段活动时,默认情况下标题处于禁用状态,并且永远不会显示。

如果设置了HEADERS_ENABLEDHEADERS_HIDDEN,则可以调用setHeadersTransitionOnBackEnabled()以支持从行中选定的内容项返回到行标题。如果未调用此方法,则默认情况下启用此功能。要自己处理后退移动,请将false传递给setHeadersTransitionOnBackEnabled()并实现您自己的后退栈处理。

显示媒体列表

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,以便用户在屏幕上浏览这些项目。以下示例代码演示了如何构建一个适配器,以使用前面的代码示例中显示的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);
}

此示例显示了适配器的静态实现。典型的媒体浏览应用程序使用来自在线数据库或 Web 服务的数据。有关使用从 Web 检索的数据的浏览应用程序示例,请参阅 Leanback 示例应用

更新背景

为了增加电视上媒体浏览应用的视觉趣味性,您可以在用户浏览内容时更新背景图像。此技术可以使与您的应用的交互更加具有电影感和趣味性。

Leanback UI 工具包提供了一个BackgroundManager类,用于更改电视应用活动的背景。以下示例演示了如何在电视应用活动中创建用于更新背景的简单方法

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

注意:前面的实现是一个简单的示例,用于说明目的。在您自己的应用中创建此函数时,在单独的线程中运行背景更新操作以获得更好的性能。此外,如果您计划响应用户滚动浏览项目来更新背景,请添加一个时间来延迟背景图像更新,直到用户停留在某个项目上。此技术避免了过多的背景图像更新。