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


Иллюстрация: Merry Mary для Skillbox Media
В интернете, помимо официальной документации от Google и нескольких HelloWorld-статей на русском языке, мало информации о работе с библиотекой Leanback. Поэтому я решил рассказать об опыте нашей компании.
Мы рассмотрим возможности BrowseFragment, который, по задумке Google, должен быть основным экраном приложения. В нашем случае это было приложение для онлайн-кинотеатра крупного медиахолдинга.
Опишу инструменты, с помощью которых мы собрали что-то подобное:

В статье я расскажу про:
- BrowseFragment и его составные элементы: TitleView, HeadersFragment & RowsFragment.
- Настройку контейнера TitleView, создание кастомных вариантов.
- Базовую настройку HeadersFragment и RowsFragment.
- Кастомизацию HeadersFragment — изменение внешнего вида всех заголовков.
- Кастомизацию RowsFragment: работу с GridListRow, создание сеток, превращение RowsFragment в отдельный экран, комбинирование элементов разного типа в одной строке.
BrowseFragment и его друзья
BrowseFragment — это фрагмент, предназначенный для создания экрана со списками элементов и заголовками. Его структура выглядит следующим образом:

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

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.
HeadersFragment & RowsFragment
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(). Он умеет их отображать.
Теперь к каждому заголовку в левой половине экрана прикрепляется список в правой половине экрана:

Рассмотрим подробнее 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 появились разделители, а у последнего элемента — подзаголовок.

Кастомизация HeadersFragment
Допустим, мы захотели изменить внешний вид заголовков и добавить иконки. Для этого необходимо создать элемент заголовка с уникальным 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)
Получаем результат:

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

Но нам это не подходит — мы хотим отобразить элементы в виде сетки. Сделать это можно с помощью кастомного 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"))
Получился следующий список:

Кастомный RowsFragment
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 будут отображаться всегда.

RowsFragment как отдельный экран
Выше были рассмотрены варианты с 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, переключение между которыми происходит при выборе нового заголовка:

Элементы разного типа в одной строке
Иногда в один 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))
Теперь в одном списке могут присутствовать элементы разных типов:

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