
Привет, сообщество! Меня зовут Илья Бу. и в этой статье я хочу с вами поделиться болью (опытом), как нам в приложении PREMIER на ANDROID TV пришлось реализовать не совсем стандартный UI. К счастью (нет), у нас есть библиотека Leanback от Jetpack, которая призвана упростить (точно нет) разработку приложений на Android TV для разработчиков.
В данной статье мы рассмотрим, как реализовать обычный экран Android на Android TV. Интересно? Тогда погнали!
Зачем все это нужно?
В мобильных приложениях мы часто пользуемся экранами, где есть категории или подкатегории. И в основном, когда такой экран необходимо отобразить, используется TabLayout (для категорий контента), и ниже отобразить — RecyclerView, в котором будет выводиться список с элементами по категории. В нашем приложении один из таких экранов выглядит следующим образом:

Тут не должно возникнуть никаких сложностей, и на мобилке мы бы сделали что-то типа такого:
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) initViews() initTabLayout() initRecycler() } private fun initViews() { tabLayout = findViewById(R.id.tabLayout) recyclerView = findViewById(R.id.recyclerView) } private fun initTabLayout() { for (tab in tabs) { tabLayout.addTab(tabLayout.newTab().setText(tab)) } tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { // Add code for change category and reload list here } override fun onTabUnselected(tab: TabLayout.Tab?) = Unit override fun onTabReselected(tab: TabLayout.Tab?) = Unit }) } private fun initRecycler() { recyclerView.adapter = CategoriesAdapter(this, tabs) }
В коде выше мы инициализируем наши вьюшки, инициализируем TabLayout, RecyclerView и в какой-то момент обновляем данные для RecyclerView.Adapter.
Но что же будет в Android TV? Давайте попробуем разобраться.
Leanback — что это?
Для начала необходимо понять, как отображаются привычные для нас экраны в Leanback. В упрощенном виде все отображение строится на RecyclerView. Основным компонентом отображения является BrowseFragment

Этот фрагмент используется для отображения контента пользователям, по которому можно перемещаться с помощью пульта (т. е. настроены механизмы изменения состояния фокуса элементов при переключении через пульт).
Если работу Leanback представить в виде схемы, то она будет выглядеть следующим образом:

Разберем данную схему поподробнее. Главным элементом отображения является LeanbackFragment. Он представляет любой из фрагментов, предоставленных в Leanback.
Рассмотрим два фрагмента:
BrowseSupportFragment— используется для реализации экрана с браузером каталога;DetailsSupportFragment— используется для реализации экрана с подробностями.
Каждый из фрагментов можно рассматривать как представление RecyclerView. Он отображает строки (Row), предоставляемые адаптером. Адаптер является наследником ObjectAdapter, который зависит от подкласса Presenter. Он преобразует элементы адаптера в экземпляры View, отображаемые во фрагменте.
Элементы ObjectAdapter могут быть любого типа, если есть реализация Presenter, которая умеет преобразовывать этот тип в вид.
Существует два типа презентаторов:
RowPresenterотображает объекты в виде строк;ListRowPresenterвизуализирует особый тип объектов Row, содержащих заголовок и список.
Рассмотрим VerticalGridFragment. Он позволяет отобразить список как с горизонтальными Rows, так и с вертикальными Grid Rows.
В базовом виде данный фрагмент выглядит следующим образом:

Код для отображения будет выглядеть следующим образом:
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) gridPresenter = object : VerticalGridPresenter(FocusHighlight.ZOOM_FACTOR_NONE, false) adapter = PagingDataAdapter(SinglePresenterSelector(SPAN_COUNT, this), object : DiffUtil.ItemCallback<Item>() { override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean = oldItem == newItem }) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) showTitle(true) title = "Title" }
Здесь происходит инициализация PagingDataAdapter и VerticalGridPresenter для отображения списка элементов в виде Grid.
Чтобы изменить titleView, необходимо переопределить метод onInflateTitleView:
override fun onInflateTitleView(inflater: LayoutInflater?, parent: ViewGroup?, savedInstanceState: Bundle?): View { return inflater!!.inflate(R.layout.title_view, parent, false) }
В этом случае, если R.layout.title_view включает в себя макеты, помимо тех, которые предлагает стандартная реализация Leanback, мы получим:
Проблемы с переключением фокусов между title и list;
Проблемы с анимацией скрытия/отображения title при скролле list вниз/вверх;
Проблемы с transition для list.
Выглядит это следующим образом:
Вторую проблему можно побороть в лоб — скрывать title без анимации:
override fun showTitle(show: Boolean) this.titleView.isVisible = show }
Третья проблема решается путем ручного расчета transaction (или высоты titleView) и установки transaction для list:
override fun showTitle(show: Boolean) this.titleView.isVisible = show val gridTranslation = titleView.calculateTitleTranslationY() if(show) { presenter.translateGrid(gridTranslation) } { presenter.clearGridTranslation() } }
Результат будет выглядеть следующим образом:

А вот при попытке решения первой проблемы возникают сложности.
Одной из проблем будет слет фокуса между title и list. И тут нам необходимо разобраться, каким образом переключается фокус между Right Menu, TitleView и GridView.
Главный layout, по которому перемещается BrowseFrameLayout. Данный layout содержит внутри себя все элементы интерфейса, и все переключения фокуса происходят внутри него. Мы можем установить onFocusSearchListener и определять, какой из View должен получить следующий фокус в зависимости от предыдущего View и направления, куда движется фокус.
private val browseFrameFocusListener = BrowseFrameLayout.OnFocusSearchListener { focused, direction -> when (direction) { View.FOCUS_DOWN -> { ... } View.FOCUS_LEFT -> { ... } View.FOCUS_RIGHT -> { ... } else -> null } }
Чтобы решить вопрос с фокусом между title и list, необходимо также установить onFocusSearchListener для BrowseFrameLayout.
Данный код иллюстрирует механизм установки фокуса для title + list в зависимости от предыдущего состояния фокуса и направления, куда требуется переместить фокус. Например, при переключении фокуса вниз (direction = View.FOCUS_DOWN) и если фокус был установлен на title, то нам необходимо установить фокус на list. Если же direction = View.FOCUS_UP и при это фокус находится на title, то необходимо переключиться на orbView (view с поиском для TV).

Стоило ли оно того
Подытожив, мы получаем ситуацию, когда при сложных и нестандартных макетах (отличающихся от google-guidelines) нам необходимо самим писать обработчики фокусов для всех элементов TV, что не всегда понятно и приятно. И при разработке под Android TV всегда необходимо учитывать риски на изучения механизмов Leanback, либо погружение в механизм управления фокусов в Android для понимания, как реализовать задуманное поведение.
