«Фокус-покус», или еще одна попытка побороть Leanback

от автора

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

  1. Проблемы с переключением фокусов между title и list;

  2. Проблемы с анимацией скрытия/отображения title при скролле list вниз/вверх;

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


ссылка на оригинал статьи https://habr.com/ru/articles/710088/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *