Разработка под Android TV с применением нативных компонентов из Leanback

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

Игорь Дубровин

эксперт

статус

Android-разработчик в аутсорс-продакшне FINCH.


об авторе

Программирует на Kotlin и Java. Любит кодить и отдыхать на пляже.


Ссылки


* Мнение автора может не совпадать с мнением редакции

Привет, меня зовут Игорь Дубровин, я Android-разработчик в компании FINCHСегодня я хотел бы рассказать про разработку под Android TV с помощью Leanback. В интернете, помимо официальной документации от Google и нескольких HelloWorld-статей на русском языке, достаточно мало информации по этой теме. Поэтому решил рассказать об опыте нашей компании.

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

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

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

  1. BrowseFragment и его составные элементы: TitleView, HeadersFragment & RowsFragment.
  2. Настройку контейнера TitileView, создание кастомных вариантов.
  3. Базовую настройку HeadersFragment и RowsFragment.
  4. Кастомизацию HeadersFragment — изменение внешнего вида всех заголовков.
  5. Кастомизацию RowsFragment: работу с GridListRow, создание сеток, превращение RowsFragment в отдельный экран, комбинирование элементов разного типа в одной строке.

BrowseFragment и его друзья

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

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

TitleView

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

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

  1. Установив searchAffordanceColor = context.getColorRes (R.color.search_opaque)
    При таком подходе изменится только цвет круга.
  2. Установив 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>

Вот и все, у нас теперь есть кастомная TitleView.

HeadersFragment & RowsFragment

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

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

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

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

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

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

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

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

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

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

  1. DividerRow — разделитель между заголовками.
  2. 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. Она не ограничивается только этим шаблоном. Их много, настолько, что в совокупности хватит для создания полноценного приложения Android TV с учётом всех гайдлайнов. Помимо шаблонов экранов, в библиотеке есть готовые шаблоны карточек контента, о которых я расскажу в следующий раз.

Здесь ссылка на github с проектом.

Курс

Профессия Android-разработчик


Освойте тонкости создания приложений для самой популярной мобильной платформы: изучите архитектурные подходы, популярные библиотеки, Unit- и UI-тестирование. Вместе мы создадим приложение и выложим его в Google Play, даже если до этого вы никогда не программировали!

Хочешь получать крутые статьи по программированию?
Подпишись на рассылку Skillbox