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

Комментарии (1)


  1. qavan
    06.06.2023 09:29

    Интересно было бы ещё посмотреть на страдрания используя Compose TV