Разработка под Android TV с применением нативных компонентов из Leanback
2026-02-21 01:39 Diff

#Руководства

  • 9 июн 2022
  • 0

Подробный разбор возможностей BrowseFragment для разработки под Android TV — на примере приложения онлайн-кинотеатра.

Иллюстрация: Merry Mary для Skillbox Media

Android-разработчик в аутсорс-продакшене FINCH. Программирует на Kotlin и Java. Любит кодить и отдыхать на пляже.

В интернете, помимо официальной документации от Google и нескольких HelloWorld-статей на русском языке, мало информации о работе с библиотекой Leanback. Поэтому я решил рассказать об опыте нашей компании.

Мы рассмотрим возможности BrowseFragment, который, по задумке Google, должен быть основным экраном приложения. В нашем случае это было приложение для онлайн-кинотеатра крупного медиахолдинга.

Опишу инструменты, с помощью которых мы собрали что-то подобное:

Скриншот: Skillbox Media

В статье я расскажу про:

BrowseFragment — это фрагмент, предназначенный для создания экрана со списками элементов и заголовками. Его структура выглядит следующим образом:

Изображение: Skillbox Media

Рассмотрим каждый из элементов.

Скриншот: Skillbox Media

TitleView — это контейнер с элементами. Он нужен для брендирования приложения TextView и ImageView, а также для добавления кнопки поиска SearchOrbView из коробки. Чтобы кнопка поиска была видимой, ей необходимо установить слушателя. Это можно сделать, вызвав setOnSearchClickedListener {//ваш код} у фрагмента.

Настроить цветовую схему кнопки можно несколькими способами:

  • Установив searchAffordanceColor = context.getColorRes (R.color.search_opaque)
    При таком подходе изменится только цвет круга.
  • Установив searchAffordanceColors = SearchOrbView.Colors (context.getColorRes (R.color.search_opaque),
    context.getColorRes (R.color.search_opaque_bright),
    context.getColorRes (R.color.search_opaque_icon)
    )

SearchOrbView.Colors имеет перегруженный конструктор, который позволяет более гибко настроить кнопку поиска.

  • Colors (@ColorInt int color) — установит цвет круга.
  • Colors (@ColorInt int color, @ColorInt int brightColor) — установит цвет круга и цвет анимации круга.
  • Colors (@ColorInt int color, @ColorInt int brightColor, @ColorInt int iconColor) — установит цвет круга, цвет анимации круга и цвет иконки.

Под анимацией круга понимается эффект мерцания, вот такой:

В TitleView можно установить текстовый тайтл title = "Finch" или логотип:

badgeDrawable = ContextCompat.getDrawable (context, R.drawable.app_icon_your_company).

Причём установить можно только один из этих элементов, и наибольший приоритет всегда у логотипа.

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

1. Создать layout для нового TitleView:

<merge xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView android:id="@+id/vTitle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentStart="true" android:layout_marginStart="40dp" android:layout_marginEnd="24dp" android:textAllCaps="true" android:textSize="20sp" android:textColor="@color/search_opaque" android:visibility="visible" /> </merge>

2. Создать View, которая реализует TitleViewAdapter.Provider, и создать сам TitleViewAdapter:

class CustomTitleView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr), TitleViewAdapter.Provider { private val titleViewAdapter = object : TitleViewAdapter() { override fun getSearchAffordanceView(): View? { return null } override fun setTitle(titleText: CharSequence?) { this@CustomTitleView.setTitle(titleText) } } init { View.inflate(context, R.layout.view_custom_title, this) } private fun setTitle(title: CharSequence?) { vTitle.text = title } override fun getTitleViewAdapter(): TitleViewAdapter { return titleViewAdapter } }

3. Создать ещё один layout, в котором будет всего один элемент — CustomTitleView:

<fm.finch.tv_test_project.extensions.browse.customTitle.CustomTitleView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/browse_title_group" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp"> </fm.finch.tv_test_project.extensions.browse.customTitle.CustomTitleView>

4. Затем создать style для активити:

<style name="BrowseCustomTitleTheme" parent="Leanback.CustomTitle" /> <style name="Leanback.CustomTitle" parent="@style/Theme.Leanback.Browse"> <item name="browseTitleViewLayout">@layout/title_view</item> <item name="browseRowsMarginTop">60dp</item> </style>

И установить её как тему нашей активити в манифесте:

<activity android:name=".MainActivity" android:theme="@style/BrowseCustomTitleTheme"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> </intent-filter> </activity>

Вот и всёмолчанию обрабатывает onBackPressed(). Если, у нас теперь есть кастомная TitleView.

BrowseSupportFragment — это фрагмент, предназначенный для отрисовки элементов своего адаптера (ObjectAdapter) в виде строк вертикального списка. Визуально этот фрагмент делится на две части, на HeadersFragment — список заголовков в левой части экрана и RowsFragment — контейнер для контента в правой части экрана. Эти фрагменты работают в связке, и BrowseFragment делегирует им отрисовку элементов своего адаптера.

Все элементы, передаваемые в адаптер BrowseFragment, должны быть подклассами Row, так как этот объект несёт информацию о заголовке и контенте для отображения.

HeadersFragment предназначен для отрисовки и взаимодействия с HeaderItem, которые являются частью Row, переданного в адаптер BrowseFragment.

RowsFragment предназначен для отрисовки и взаимодействия с элементами своего ObjectAdapter. Это не адаптер BrowseFragment, и он не является частью Row, переданного в BrowseFragment. В него передаются объекты, описывающие контент.

Продемонстрирую сказанное на примере. Создадим Presenter, который будет передавать текст в TextView:

class TextPresenter : Presenter() { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { val view = TextView(parent.context) view.layoutParams = ViewGroup.LayoutParams(300, 200) view.isFocusable = true view.isFocusableInTouchMode = true view.setBackgroundColor( ContextCompat.getColor( parent.context, android.R.color.background_light ) ) view.gravity = Gravity.CENTER view.setTextColor(Color.BLACK) return Presenter.ViewHolder(view) } override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) { val textView = viewHolder.view as TextView val str = item as String textView.text = str } override fun onUnbindViewHolder(viewHolder: ViewHolder) { val textView = viewHolder.view as TextView textView.text = "" } }

Затем создадим все необходимые адаптеры, заполним их данными и передадим BrowseFragment для отображения данных:

class MainFragment : BrowseSupportFragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) initView(savedInstanceState) } private fun initView(savedInstanceState: Bundle?) { headersState = HEADERS_HIDDEN title = "Finch" val browseAdapter = ArrayObjectAdapter(ListRowPresenter()) val rowsAdapter = ArrayObjectAdapter(TextPresenter()) rowsAdapter.add("Элемент 1") rowsAdapter.add("Элемент 2") rowsAdapter.add("Элемент 3") val firstHeader = HeaderItem("Заголовок 1") val secondHeader = HeaderItem("Заголовок 2") val thirdHeader = HeaderItem("Заголовок 3") browseAdapter.add(ListRow(firstHeader, rowsAdapter)) browseAdapter.add(ListRow(secondHeader, rowsAdapter)) browseAdapter.add(ListRow(thirdHeader, rowsAdapter)) adapter = browseAdapter } }

Каждому адаптеру передадим Presenter, который будет рисовать элементы:

  • Для BrowseFragment здесь использована стандартная реализация ListRowPresenter. Этот Presenter умеет работать с ListRow и визуализирует ListRow, используя HorizontalGridView, помещённый в ListRowView.
  • Для адаптера RowsFragment мы использовали свою реализацию TextPresenter(), так как передаём в него элементы типа String и TextPresenter(). Он умеет их отображать.

Теперь к каждому заголовку в левой половине экрана прикрепляется список в правой половине экрана:

Скриншот: Skillbox Media

Рассмотрим подробнее HeadersFragment.

В базовой реализации HeadersFragment сразу виден пользователю. Это поведение можно изменить с помощью метода setHeadersState(int). К нему нужно обратиться во время вызова onActivityCreated() и передать одно из состояний:

  • HEADERS_ENABLED — фрагмент виден пользователю.
  • HEADERS_HIDDEN — фрагмент свёрнут.
  • HEADERS_DISABLED — фрагмент полностью скрыт с экрана.

BrowseFragment по умолчанию обрабатывает onBackPressed (). Если HeadersFragment свёрнут, то по нажатии пользователем кнопки «Назад» на пульте или джойстике он переходит в активное состояние и становится виден. Такое поведение отключается передачей параметра false в метод setHeadersTransitionOnBackEnabled ().

Чтобы управлять состоянием HeadersFragment вручную, можно использовать метод startHeadersTransition (boolean). В зависимости от входного параметра фрагмент заголовков либо будет показан (при передаче true), либо свёрнут (при передаче false).

Если есть необходимость прослушивать начало и конец анимации перехода HeadersFragment из свёрнутого состояния в активное и наоборот, то можно установить слушателя:
setBrowseTransitionListener(BrowseSupportFragment.BrowseTransitionListener).

Существует ещё несколько реализаций Row, которые оказывают влияние только на HeadersFragment:

  • DividerRow — разделитель между заголовками.
  • SectionRow — подзаголовок.

Изменим код следующим образом:

browseAdapter.add(ListRow(firstHeader, rowsAdapter)) browseAdapter.add(DividerRow()) browseAdapter.add(ListRow(secondHeader, rowsAdapter)) browseAdapter.add(DividerRow()) browseAdapter.add(ListRow(thirdHeader, rowsAdapter)) browseAdapter.add(SectionRow("Подзаголовок 1"))

Что получилось: между элементами HeadersFragment появились разделители, а у последнего элемента — подзаголовок.

Скриншот: Skillbox Media

Допустим, мы захотели изменить внешний вид заголовков и добавить иконки. Для этого необходимо создать элемент заголовка с уникальным Presenter, а также создать PresenterSelector — он выполняет роль Presenter для каждого элемента списка.

Начнём с элемента заголовка:

class IconHeaderItem( name: String, id: Long = -1, val iconResId: Int = NO_ICON ) : HeaderItem(id, name) { companion object { val NO_ICON = -1 } }

Мы расширили стандартный HeaderItem, добавив возможность устанавливать id‑иконки из ресурсов. Сделаем вёрстку для нового заголовка:

<TextView xmlns:android="http://schemas.android.com/apk/res/android" android:focusable="true" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" android:drawablePadding="16dp" android:textSize="18sp"/>

Создадим свой презентер, который умеет отрисовывать IconHeaderItem:

class IconRowHeaderPresenter : RowHeaderPresenter() { override fun onCreateViewHolder(viewGroup: ViewGroup): ViewHolder { val view = LayoutInflater .from(viewGroup.context) .inflate(R.layout.item_icon_header, viewGroup, false) val viewHolder = ViewHolder(view) setSelectLevel(viewHolder, 0f) return viewHolder } override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, data: Any) { val row = data as? Row val iconHeaderItem = row?.headerItem as? IconHeaderItem iconHeaderItem?.let { val tv = viewHolder .view .textView if (iconHeaderItem.iconResId != IconHeaderItem.NO_ICON) { tv.setCompoundDrawablesWithIntrinsicBounds(iconHeaderItem.iconResId, 0, 0, 0) } tv.text = iconHeaderItem.name } } override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) { val tv = viewHolder .view .textView tv.text = "" tv.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) } }

Установим новый PresenterSelector для заголовков. Для этого у BrowseFragment есть метод setHeaderPresenterSelector:

setHeaderPresenterSelector(object : PresenterSelector() { val presenter = IconRowHeaderPresenter() val defaultPresenterSelector = headersSupportFragment.presenterSelector override fun getPresenter(data: Any): Presenter { return if ((data as? Row)?.headerItem is IconHeaderItem) presenter else defaultPresenterSelector.getPresenter(data) } })

Теперь смотрим на заголовок Row. Если он IconHeaderItem, то возвращаем Presenter, который умеет с ним работать — в данном случае IconRowHeaderPresenter. Заменим заголовки на новые:

val firstHeader = IconHeaderItem("Заголовок 1", iconResId = R.drawable.ic_header_item) val secondHeader = IconHeaderItem("Заголовок 2", iconResId = R.drawable.ic_header_item) val thirdHeader = IconHeaderItem("Заголовок 3", iconResId = R.drawable.ic_header_item)

Получаем результат:

Скриншот: Skillbox Media

Таким же образом можно кастомизировать и подзаголовки в SectionRow или разделители DividerRow.

Допустим, нам нужно отобразить в правой части экрана большое количество элементов. По стандарту все они будут отображены в одну строку:

Скриншот: Skillbox Media

Но нам это не подходит — мы хотим отобразить элементы в виде сетки. Сделать это можно с помощью кастомного Row.

Создадим свой GridListRow, который будет отображать данные в виде сетки, а не списка:

class GridListRow( header: HeaderItem, adapter: ObjectAdapter, val numRows: Int ) : ListRow( header, adapter )

Затем расширим возможности ListRowPresenter и научим его работать с GridListRow:

class GridListRowPresenter : ListRowPresenter() { override fun onBindRowViewHolder(holder: RowPresenter.ViewHolder, item: Any) { (holder as ViewHolder).gridView.setNumRows((item as? GridListRow)?.numRows ?: 1) super.onBindRowViewHolder(holder, item) } override fun isUsingDefaultShadow(): Boolean = false }

Теперь установим GridListRow в качестве Presenter адаптера BrowseFragment:

val browseAdapter = ArrayObjectAdapter(GridListRowPresenter())

Теперь используем GridListAdapter в RowsFragment:

browseAdapter.add(GridListRow(firstHeader, rowsAdapter, 2)) browseAdapter.add(DividerRow()) browseAdapter.add(GridListRow(secondHeader, rowsAdapter, 3)) browseAdapter.add(DividerRow()) browseAdapter.add(GridListRow(thirdHeader, rowsAdapter, 4)) browseAdapter.add(SectionRow("Подзаголовок 1"))

Получился следующий список:

Скриншот: Skillbox Media

BrowseFragment позволяет заменить стандартную реализацию RowsFragment на кастомную при помощи FragmentFactory. Эта фабрика отвечает за создание фрагмента для текущего элемента.

Сделаем так, чтобы RowsFragment всегда показывал внутренние заголовки. Для начала создадим свою реализацию RowsFragment для работы со списками:

class ListRowsFragment : RowsSupportFragment() { override fun setExpand(expand: Boolean) { super.setExpand(true) } companion object { fun newInstance() = ListRowsFragment() } }

Здесь мы переопределили метод setExpand, чтобы всегда передавать true в качестве аргумента родительского метода. Теперь заголовки всегда будут показываться.

Далее реализуем фабрику FragmentFactory, которая будет возвращать ListRowFragment:

private val listRowFragmentFactory = object : FragmentFactory<Fragment>() { override fun createFragment(row: Any): Fragment? = ListRowsFragment.newInstance() }

И установим её в качестве фабрики, возвращающей фрагмент для GridListRow:

mainFragmentRegistry.registerFragment(GridListRow::class.java, listRowFragmentFactory)

Теперь заголовки RowsFragment будут отображаться всегда.

Скриншот: Skillbox Media

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

Реализовать такое поведение можно через PageRow совместно с FragmentFactory. Для начала создадим фрагмент, который будет представлять собой отдельную страницу:

class PageRowsFragment : RowsSupportFragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) initView() } private fun initView() { val pageId = arguments?.getLong(PAGE_ID) val headerTitle = pageId?.let { "Страница $pageId" } ?: "" val outerAdapter = ArrayObjectAdapter(ListRowPresenter()) val rowsAdapter = ArrayObjectAdapter(TextPresenter()) val commonHeader = HeaderItem(headerTitle) rowsAdapter.add("Элемент 1") rowsAdapter.add("Элемент 2") rowsAdapter.add("Элемент 3") outerAdapter.add(ListRow(commonHeader, rowsAdapter)) outerAdapter.add(ListRow(commonHeader, rowsAdapter)) outerAdapter.add(ListRow(commonHeader, rowsAdapter)) outerAdapter.add(ListRow(commonHeader, rowsAdapter)) adapter = outerAdapter } companion object { private const val PAGE_ID = "page id" fun newInstance(pageId: Long) = PageRowsFragment() .apply { val bundle = Bundle() bundle.putLong(PAGE_ID, pageId) arguments = bundle } } }

У RowsFragment, так же как и у BrowseFragment, есть общий внешний и внутренний адаптеры для каждого списка элементов. При создании PageRowsFragment мы передаём ему id текущего элемента заголовка BrowseFragment и устанавливаем в качестве заголовка списка.

Создадим FragmentFactory, которая будет возвращать PageRowFragment:

private val pageRowFragmentFactory = object : FragmentFactory<Fragment>() { override fun createFragment(row: Any): Fragment? = (row as? PageRow) ?.headerItem ?.id ?.let { PageRowsFragment.newInstance(it) } }

Установим его в качестве фабрики, возвращающей фрагменты для PageRow:

mainFragmentRegistry.registerFragment(PageRow::class.java, pageRowFragmentFactory)

Теперь каждый элемент заголовка BrowseFragment имеет свой собственный RowsFragment, переключение между которыми происходит при выборе нового заголовка:

Скриншот: Skillbox Media

Иногда в один ListRow нужно добавить объекты разного типа. Для этого под элемент каждого типа необходимо создать свой Presenter, который будет возвращать PresenterSelector. В Leanback уже есть готовая реализация PresenterSelector, которая возвращает Presenter для каждого элемента списка по типу его класса — ClassPresenterSelector.

Создадим два класса TextItem и ImageItem. В первый будем передавать текст для элемента, во второй — id изображения из ресурсов:

data class TextItem( val text: String ) data class ImageItem( @DrawableRes val imageId: Int )

Перепишем TextPresenter таким образом, чтобы он умел работать с элементами TextItem. В rowsAdapter будем добавлять не String, а TextItem:

rowsAdapter.add(TextItem("Элемент 1"))

Добавим ImagePresenter, работающий с ImageItem:

class ImagePresenter : Presenter() { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { val view = ImageView(parent.context) view.layoutParams = ViewGroup.LayoutParams(600, 400) view.isFocusable = true view.isFocusableInTouchMode = true view.scaleType = ImageView.ScaleType.CENTER_CROP view.setBackgroundColor( ContextCompat.getColor( parent.context, android.R.color.background_light ) ) return ViewHolder(view) } override fun onBindViewHolder(viewHolder: Presenter.ViewHolder?, item: Any?) = (viewHolder as ViewHolder).bind(item as ImageItem) override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder?) = (viewHolder as ViewHolder).unbind() class ViewHolder(private val imageView: ImageView) : Presenter.ViewHolder(imageView) { private var requestManager: RequestManager? = null fun bind(imageItem: ImageItem) { requestManager = Glide.with(imageView) requestManager?.load(imageItem.imageId)?.into(imageView) } fun unbind() { requestManager?.clear(imageView) imageView.setImageDrawable(null) } } }

ImagePresenter загружает изображение из ресурсов, показывает его и помещает в ImageView. Создадим ClassPresenterSelector и укажем, какие данные отображает каждый Presenter:

val rowsPresenterSelector = ClassPresenterSelector() .addClassPresenter(ImageItem::class.java, ImagePresenter()) .addClassPresenter(TextItem::class.java, TextPresenter())

Затем передадим этот презентер в адаптер RowsFragment:

val rowsAdapter = ArrayObjectAdapter(rowsPresenterSelector)

и добавим несколько элементов типа ImageItem:

rowsAdapter.add(ImageItem(R.drawable.image1)) rowsAdapter.add(ImageItem(R.drawable.image2)) rowsAdapter.add(ImageItem(R.drawable.image3))

Теперь в одном списке могут присутствовать элементы разных типов:

Скриншот: Skillbox Media

Мы рассмотрели возможности BrowseFragment, который является частью библиотеки Leanback. Но Leanback не ограничивается этим шаблоном. Она настолько богатая, что её элементов хватит для создания полноценного приложения Android TV с учётом всех гайдлайнов.

А здесь можно посмотреть исходники проекта.

Бесплатный курс по Python ➞
Мини-курс для новичков и для опытных кодеров. 4 крутых проекта в портфолио, живое общение со спикером. Кликните и узнайте, чему можно научиться на курсе. Смотреть программу