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

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

Медиа-приложение, работающее на телевизоре, должно позволять пользователям просматривать предложения контента, делать выбор и начинать воспроизведение контента. Опыт просмотра контента должен быть простым и интуитивно понятным, а также визуально приятным и привлекательным.

В этом руководстве обсуждается, как использовать классы, предоставляемые библиотекой 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)
    }
...

Ява

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() помещает указанный рисуемый ресурс в правый верхний угол фрагмента просмотра, как показано на рисунках 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 и реализуйте абстрактные методы для создания, привязки и отмены привязки держателя представления. В следующем примере показано, как связать держатель представления с двумя представлениями: 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
    }
}

Ява

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

    Ява

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

Ява

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

Ява

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
}

Ява

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 предоставляет класс BackgroundManager для изменения фона активности вашего ТВ-приложения. В следующем примере показано, как создать простой метод для обновления фона в активности вашего ТВ-приложения:

Котлин

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

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