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

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

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

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

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

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

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

Скриншот: Skillbox Media

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

BrowseFragment и его друзья

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

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

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

TitleView

Скриншот: 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.

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(). Он умеет их отображать.

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

Скриншот: 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

Кастомизация 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)

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

Скриншот: Skillbox Media

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

Подробнее о RowsFragment

Кастомный ListRow

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

Скриншот: 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

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

Скриншот: Skillbox Media

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

Скриншот: 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 с учётом всех гайдлайнов.

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

Изучайте IT на практике — бесплатно

Курсы за 2990 0 р.

Я не знаю, с чего начать
Освойте топовые нейросети за три дня. Бесплатно
Знакомимся с ChatGPT-4, DALLE-3, Midjourney, Stable Diffusion, Gen-2 и нейросетями для создания музыки. Практика в реальном времени. Подробности — по клику.
Узнать больше
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована