Создать браузер каталога

Создавайте более эффективные проекты с помощью Compose.
Создавайте красивые пользовательские интерфейсы с минимальным количеством кода, используя Jetpack Compose для Android TV OS.

Медиаприложение для телевизора должно позволять пользователям просматривать предлагаемый контент, выбирать его и начинать воспроизведение. Процесс просмотра контента должен быть простым и интуитивно понятным, а также визуально привлекательным и интересным.

В этом руководстве рассматривается, как использовать классы, предоставляемые устаревшей библиотекой androidx.leanback, для реализации пользовательского интерфейса для просмотра музыки или видео из медиакаталога вашего приложения.

Примечание: В приведенном здесь примере реализации используется класс BrowseSupportFragment , а не устаревший класс BrowseFragment . Класс BrowseSupportFragment расширяет класс AndroidX Fragment , что помогает обеспечить согласованное поведение на разных устройствах и версиях Android.

Главный экран приложения

Рисунок 1. В демонстрационном приложении Leanback в фрагменте просмотра отображаются данные видеокаталога.

Создайте макет для просмотра медиафайлов.

Класс BrowseSupportFragment из набора инструментов Leanback UI позволяет создать основной макет для просмотра категорий и строк медиафайлов с минимальным количеством кода. В следующем примере показано, как создать макет, содержащий объект 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>

Основное приложение устанавливает это представление, как показано в следующем примере:

Котлин

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 заполняют представление данными о видео и элементами пользовательского интерфейса, а также устанавливают параметры макета, такие как значок и заголовок, и включаются ли заголовки категорий.

Для получения дополнительной информации о настройке элементов пользовательского интерфейса см. раздел « Настройка элементов пользовательского интерфейса» . Для получения дополнительной информации о скрытии заголовков см. раздел « Скрытие или отключение заголовков» .

Подкласс приложения, реализующий методы 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()
    }
    ...

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

Настройка элементов пользовательского интерфейса

В предыдущем примере приватный метод setupUIElements() вызывает несколько методов BrowseSupportFragment для оформления браузера каталога медиафайлов:

  • setBadgeDrawable() размещает указанный ресурс изображения в правом верхнем углу фрагмента просмотра, как показано на рисунках 1 и 2. Этот метод заменяет строку заголовка ресурсом изображения, если также вызывается setTitle() . Ресурс изображения должен иметь высоту 52 dp.
  • setTitle() устанавливает строку заголовка в правом верхнем углу фрагмента просмотра, если только не вызывается setBadgeDrawable() .
  • setHeadersState() и setHeadersTransitionOnBackEnabled() скрывают или отключают заголовки. Дополнительную информацию см. в разделе « Скрытие или отключение заголовков» .
  • setBrandColor() устанавливает цвет фона для элементов пользовательского интерфейса во фрагменте просмотра, в частности, цвет фона заголовка, с заданным значением цвета.
  • 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 и реализуйте абстрактные методы для создания, привязки и отмены привязки ViewHolder. В следующем примере показано, как привязать ViewHolder к двум представлениям: ImageView и TextView .

Котлин

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() :

    Котлин

    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() для установки презентера для заголовка строки, как показано в следующем примере.

Котлин

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 .

Скрыть или отключить заголовки

Иногда вам не нужно, чтобы заголовки строк отображались, например, когда категорий недостаточно для создания прокручиваемого списка. Вызовите метод BrowseSupportFragment.setHeadersState() во время выполнения метода onActivityCreated() фрагмента, чтобы скрыть или отключить заголовки строк. Метод setHeadersState() устанавливает начальное состояние заголовков во фрагменте просмотра, передавая в качестве параметра одну из следующих констант:

  • HEADERS_ENABLED : при создании фрагмента просмотра заголовки включаются и отображаются по умолчанию. Заголовки отображаются, как показано на рисунках 1 и 2 на этой странице.
  • HEADERS_HIDDEN : при создании фрагмента просмотра заголовки по умолчанию включаются и скрываются. Раздел заголовка экрана свернут, как показано на рисунке в разделе « Предоставление представления карточки» . Пользователь может выбрать свернутый раздел заголовка, чтобы развернуть его.
  • HEADERS_DISABLED : при создании фрагмента просмотра заголовки по умолчанию отключены и никогда не отображаются.

Если заданы значения HEADERS_ENABLED или HEADERS_HIDDEN , вы можете вызвать setHeadersTransitionOnBackEnabled() для поддержки возврата к заголовку строки из выбранного элемента содержимого в строке. Эта функция включена по умолчанию, если вы не вызываете метод. Чтобы самостоятельно обрабатывать возврат, передайте false в setHeadersTransitionOnBackEnabled() и реализуйте собственную обработку стека возврата.

Отобразить списки медиафайлов

Класс BrowseSupportFragment позволяет определять и отображать категории медиаконтента и медиаэлементы из каталога медиафайлов с помощью адаптеров и презентеров. Адаптеры позволяют подключаться к локальным или онлайн-источникам данных, содержащим информацию из вашего каталога медиафайлов. Адаптеры используют презентеры для создания представлений и привязки данных к этим представлениям для отображения элемента на экране.

В приведенном ниже примере кода показана реализация 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
    }
}

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 показанного в предыдущем примере кода:

Котлин

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

В этом примере показана статическая реализация адаптеров. Типичное приложение для просмотра мультимедиа использует данные из онлайн-базы данных или веб-сервиса. Пример приложения для просмотра, использующего данные, полученные из интернета, можно найти в примере приложения Leanback .

Обновите фон

Чтобы добавить визуальной привлекательности приложению для просмотра медиаконтента на телевизоре, можно обновлять фоновое изображение по мере того, как пользователи просматривают контент. Этот прием может сделать взаимодействие с вашим приложением более кинематографичным и приятным.

В наборе инструментов Leanback UI Toolkit есть класс BackgroundManager для изменения фона вашего приложения для телевизора. В следующем примере показано, как создать простой метод для обновления фона в вашем приложении для телевизора:

Котлин

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

Java

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

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

Примечание: Предыдущая реализация — это простой пример для иллюстрации. При создании этой функции в собственном приложении запускайте обновление фона в отдельном потоке для повышения производительности. Кроме того, если вы планируете обновлять фон в ответ на прокрутку пользователем элементов, добавьте задержку обновления фонового изображения до тех пор, пока пользователь не остановится на каком-либо элементе. Этот метод позволяет избежать чрезмерного количества обновлений фонового изображения.