Привет, сообщество! Меня зовут Илья Бу. и в этой статье я хочу с вами поделиться болью (опытом), как нам в приложении 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 для понимания, как реализовать задуманное поведение.
qavan
Интересно было бы ещё посмотреть на ст
радрания используя Compose TV