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

Продолжаем разбираться с Leanback. Сегодня создадим карточки контента, которые можно использовать, например, в списке элементов.

Игорь Дубровин

эксперт

об авторе

Android-разработчик в аутсорс-продакшне FINCH. Программирует на Kotlin и Java. Любит кодить и отдыхать на пляже.


Ссылки


* Мнение автора может не совпадать с мнением редакции


Хотите предложить статью
в Skillbox Media?

Click Me!


В предыдущей статье я рассказал о том, как создавать главный экран приложения со списком элементов. Тогда каждый элемент отрисовывался при помощи своей уникальной карточки. В этот раз я расскажу о том, как создаются эти карточки и какие инструменты работы доступны «из коробки».

Leanback Cards

Допустим, мы хотим создать раздел приложения, где карточки будут выглядеть следующим образом:

В Leanback уже есть готовая карточка, которую можно использовать в своем приложении, — это ImageCardView. Эта карточка — подкласс BaseCardView, которая достаточно гибко настраивается. Рассмотрим, из чего она состоит:

  1. ImageView — основная картинка.
  2. ViewGroup (infoArea) — контейнер для дополнительных элементов, таких как:
  • ImageView — иконка;
  • TextView — заголовок;
  • TextView — описание.

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

data class LeanbackCardItem(
  val title: String,
  val content: String,
  @DrawableRes
  val image: Int,
  @DrawableRes
  val badgeIcon: Int
)

Затем создадим Presenter, который будет связывать данные из LeanbackCardItem c ImageCardView. Presenter нужен для отрисовки элементов контента, которые были переданы в адаптер — он связывает каждый элемент контента с UI-отображением, то есть карточкой контента.

class LeanbackCardPresenter : Presenter() {

  override fun onCreateViewHolder(viewGroup: ViewGroup): Presenter.ViewHolder =
     ViewHolder(createView(viewGroup))

  private fun createView(viewGroup: ViewGroup) =
     viewGroup
        .context
        .let { context ->
           ImageCardView(context)
              .apply {
                 isFocusable = true
                 isFocusableInTouchMode = true
                 setMainImageDimensions(
                    context.getDimensionPixelSizeRes(R.dimen.card_width),
                    context.getDimensionPixelSizeRes(R.dimen.card_height)
                 )
              }
        }

  override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) =
     (viewHolder as ViewHolder)
        .bind(item as LeanbackCardItem)

  override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) =
     (viewHolder as ViewHolder)
        .unbind()

  private inner class ViewHolder(view: ImageCardView) : Presenter.ViewHolder(view) {

     private var requestManager: RequestManager? = null
     private val imageTarget = ImageCardViewTarget(view)

     fun bind(item: LeanbackCardItem) = with(view as ImageCardView) {
        item.run {
           requestManager = Glide.with(view)
           requestManager
              ?.load(image)
              ?.into(imageTarget)
           titleText = title
           contentText = content
           badgeImage = context.getDrawable(badgeIcon)
        }
     }

     fun unbind() = with(view as ImageCardView) {
        requestManager?.clear(imageTarget)
        mainImage = null
        badgeImage = null
        titleText = ""
        contentDescription = ""
     }
  }
}

Каждый Presenter требует реализовать 3 метода:

  1. fun onCreateViewHolder(viewGroup: ViewGroup): Presenter.ViewHolder — создание ViewHolder, смысл которого — в переиспользовании уже созданной View. Подобное происходит с ViewHolder адаптера RecyclerView.
  2. fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) — во время вызова этого метода необходимо связать данные определенного элемента с его UI-представлением.
  3. fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) — отвязывание View от его элемента и очистка ресурсов, связанных с данным элементом.

У созданной ImageCardView установим два флага isFocusable = true и isFocusableInTouchMode = true — это необходимо для того, чтобы наша View получала фокус от контроллера управления.

С помощью метода setMainImageDimensions(int width, int height) можно установить размер основной картинки. Также для упрощения работы, связанной с загрузкой изображений, мы создали таргет для Glide ImageCardViewTarget, который умеет работать с ImageCardView.

class ImageCardViewTarget(view: ImageCardView) : CustomViewTarget<ImageCardView, Drawable>(view) {
  override fun onLoadFailed(errorDrawable: Drawable?) {
     view.mainImage = errorDrawable
  }

  override fun onResourceCleared(placeholder: Drawable?) {
     view.mainImage = placeholder
  }

  override fun onResourceReady(
     resource: Drawable,
     transition: Transition<in Drawable>?
  ) {
     view.mainImage = resource
  }
}

Добавим код создания элементов и инициализации адаптеров во фрагмент:

class MainFragment : RowsSupportFragment() {

  override fun onActivityCreated(savedInstanceState: Bundle?) {
     super.onActivityCreated(savedInstanceState)
     initViewWithLeanbackCard(savedInstanceState)
     setOnItemViewClickedListener { _, _, _, _ -> }
  }

  private fun initViewWithLeanbackCard(savedInstanceState: Bundle?) {
     val cardAdapter = ArrayObjectAdapter(LeanbackCardPresenter())
     cardAdapter.addAll(0, getLeanbackCardItems())
     setCardAdapter(cardAdapter)
  }

  private fun setCardAdapter(cardAdapter: ArrayObjectAdapter) {
     val header = HeaderItem("Finch")
     val rowPresenter = getRowPresenter()
     val rowsAdapter = ArrayObjectAdapter(rowPresenter)
     rowsAdapter.add(ListRow(header, cardAdapter))
     adapter = rowsAdapter
  }

  private fun getRowPresenter() =
     ListRowPresenter()
        .apply {
           context?.let { context ->
              headerPresenter = TitleHeaderPresenter(
                 context.getDimensionRes(R.dimen.title_text_size),
                 context.getDimensionPixelSizeRes(R.dimen.title_bottom_padding)
              )
           }
        }

  private fun getLeanbackCardItems(): List<LeanbackCardItem> =
     listOf(
        LeanbackCardItem(
           title = "title 1",
           content = "content 1",
           image = R.drawable.image1,
           badgeIcon = R.drawable.app_icon_your_company
        ),
        LeanbackCardItem(
           title = "title 2",
           content = "content 2",
           image = R.drawable.image2,
           badgeIcon = R.drawable.app_icon_your_company
        ),
        LeanbackCardItem(
           title = "title 3",
           content = "content 3",
           image = R.drawable.image3,
           badgeIcon = R.drawable.app_icon_your_company
        ),
        LeanbackCardItem(
           title = "title 4",
           content = "content 4",
           image = R.drawable.image4,
           badgeIcon = R.drawable.app_icon_your_company
        ),
        LeanbackCardItem(
           title = "title 5",
           content = "content 5",
           image = R.drawable.image5,
           badgeIcon = R.drawable.app_icon_your_company
        ),
        LeanbackCardItem(
           title = "title 6",
           content = "content 6",
           image = R.drawable.image6,
           badgeIcon = R.drawable.app_icon_your_company
        )
     )
}

Посмотрим на результат:

Близко, но нет. Карточки еще нужно доработать: иконка пока находится с правой стороны, а infoArea расположена на картинке и имеет селектор у выделенной карточки.

Управлять компонентами ImageCardView можно путем расширения стиля Widget.Leanback.ImageCardViewStyle и установки свойства lbImageCardViewType с одним из следующих поддерживаемых значений: Title, Content, IconOnRight, IconOnLeft, ImageOnly или их комбинацией.

Создадим стиль ImageCardViewStyle и укажем для свойства lbImageCardViewType следующие значения IconOnLeft|Title|Content. Комбинация этих значений позволит нам разместить иконку с левой стороны от описания:

<style name="ImageCardViewStyle" parent="Widget.Leanback.ImageCardViewStyle">
   <item name="lbImageCardViewType">IconOnLeft|Title|Content</item>
</style>

Применить этот стиль к ImageCardView можно через основную тему приложения, установив ее в качестве значения imageCardViewStyle. В данном случае этот стиль будет применен ко всем карточкам приложения, но для большей гибкости создадим отдельную тему:

<style name="ImageCardTheme" parent="Theme.Leanback">
   <item name="imageCardViewStyle">@style/ImageCardViewStyle</item>
</style>

Затем применим тему к конкретной ImageCardView при помощи ContextThemeWrapper. Такой подход позволяет создавать различные стили под определенный тип карточек приложения. ImageCardView(ContextThemeWrapper(context, R.style.ImageCardTheme))

Запустим приложение и посмотрим, что получилось:

Бинго! Иконка расположена с левой стороны, а заголовок и описание отчетливо видны. Теперь сделаем селектор и градиентный бэкграунд для infoArea. Для начала создадим градиентную область gradient_background.xml:

<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
   <gradient android:angle="90"
       android:startColor="#000000"
       android:centerColor="#80000000"
       android:endColor="@android:color/transparent"/>
</shape>

Затем создадим селектор info_area_selector.xml:

<selector xmlns:android="http://schemas.android.com/apk/res/android">
   <item android:state_selected="true">
       <layer-list>
           <item android:drawable="@drawable/gradient_background" />
           <item android:gravity="bottom">
               <shape android:shape="rectangle">
                   <size android:height="2dp"/>
                   <solid android:color="#FFFF"/>
               </shape>
           </item>
       </layer-list>
   </item>
   <item android:drawable="@drawable/gradient_background" />
</selector>

Зададим селектор как бэкграунд infoArea нашей карточки. Это можно сделать через стиль imageCardViewStyle, установив его в качестве значения свойства infoAreaBackground. Стиль карточки теперь выглядит следующим образом:

<style name="ImageCardViewStyle" parent="Widget.Leanback.ImageCardViewStyle">
   <item name="lbImageCardViewType">IconOnLeft|Title|Content</item>
   <item name="infoAreaBackground">@drawable/info_area_selector</item>
</style>

Теперь осталось разместить infoArea поверх основной картинки.

У BaseCardView есть такие параметры, как cardType и infoVisibility, они также позволяют управлять элементами карточки. cardType управляет расположением областей и может принимать одно из следующих значений:

  • CARD_TYPE_MAIN_ONLY — видимой будет только основная область (у ImageCardView это основная картинка, т.е. это значение эквивалентно ImageOnly);
  • CARD_TYPE_INFO_OVER — информационная область находится поверх основной (infoArea будет размещена поверх основной картинки);
  • CARD_TYPE_INFO_UNDER — информационная область находится под основной (infoArea будет размещена под основной картинкой);
  • CARD_TYPE_INFO_UNDER_WITH_EXTRA — поддерживает третью дополнительную область, которая будет расположена под основной областью, но над информационной. Этой областью может быть view, которая добавлена в карточку с помощью метода addView(view: View) (относительно ImageCardView дополнительная область будет расположена между основной картинкой и infoArea).

infoVisibility управляет видимостью информационной области (у ImageCardView это infoArea) и может принимать одно из следующих значений:

  • CARD_REGION_VISIBLE_ALWAYS;
  • CARD_REGION_VISIBLE_ACTIVATED;
  • CARD_REGION_VISIBLE_SELECTED.

Для того чтобы разместить infoArea поверх основной картинки, установим для нашей ImageCardView cardType со значением CARD_TYPE_INFO_OVER:

cardType = BaseCardView.CARD_TYPE_INFO_OVER.

Посмотрим на результат. Получилось то, что и планировалось.

С помощью стандартных инструментов Leanback можно создавать много разных вариантов карточек контента ImageCardView, но все же варианты «из коробки» ограничены. Иногда нужны совсем нестандартные варианты.

Создание собственных карточек контента

Реализуем раздел меню для нашего приложения, который должен выглядеть следующим образом:

Для начала создадим элемент меню, который мы хотим отрисовать. Он содержит заголовок и иконку:

data class MenuItem(
  val title: String,
  @DrawableRes
  val icon: Int
)

Попробуем его реализовать при помощи ImageCardView. Создадим презентер для связывания элементов меню в ImageCardView:

class MenuPresenter : Presenter() {

  override fun onCreateViewHolder(viewGroup: ViewGroup): Presenter.ViewHolder =
     ViewHolder(createView(viewGroup))

  private fun createView(viewGroup: ViewGroup) =
     viewGroup
        .context
        .let { context ->
           ImageCardView(context)
              .apply {
                 isFocusable = true
                 isFocusableInTouchMode = true
                 setMainImageDimensions(
                    context.getDimensionPixelSizeRes(R.dimen.menu_item_width),
                    context.getDimensionPixelSizeRes(R.dimen.menu_item_height)
                 )
              }
        }

  override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) =
     (viewHolder as ViewHolder)
        .bind(item as MenuItem)

  override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) =
     (viewHolder as ViewHolder)
        .unbind()

  private inner class ViewHolder(view: ImageCardView) : Presenter.ViewHolder(view) {

     fun bind(item: MenuItem) = with(view as ImageCardView) {
        item.run {
           mainImage = view.context.getDrawable(icon)
           titleText = title
        }
     }

     fun unbind() = with(view as ImageCardView) {
        mainImage = null
        badgeImage = null
        titleText = ""
        contentDescription = ""
     }
  }
}

Добавим код создания элементов меню и инициализации адаптеров в наш фрагмент:

private fun initViewWithMenu(savedInstanceState: Bundle?) {
  val cardAdapter = ArrayObjectAdapter(MenuPresenter())
  cardAdapter.addAll(0, getMenuCardItems())
  setCardAdapter(cardAdapter)
}
private fun getMenuItems(): List<MenuItem> =
  listOf(
     MenuItem(
        title = "profile",
        icon = R.drawable.ic_tag_faces_black_24dp
     ),
     MenuItem(
        title = "subscriptions",
        icon = R.drawable.ic_subscriptions_black_24dp
     ),
     MenuItem(
        title = "history",
        icon = R.drawable.ic_history_black_24dp
     ),
     MenuItem(
        title = "settings",
        icon = R.drawable.ic_settings_black_24dp
     )
  )

Запустим и посмотрим, что получилось:

Неплохо, но далеко от идеала. Иконки растягиваются на всю область основного изображения, а изменение ее размеров приводит к изменению размеров самой карточки. Для решения этой задачи нам необходимо создать собственную карточку для отрисовки элементов меню. Назовем ее MenuView.

Для начала создадим ее layout, view_menu.xml:

<merge xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:orientation="vertical"
   android:gravity="center"
   tools:parentTag="android.widget.FrameLayout">

   <LinearLayout
       android:layout_width="@dimen/menu_item_width"
       android:layout_height="@dimen/menu_item_height"
       android:orientation="vertical"
       android:gravity="center"
       tools:parentTag="android.widget.LinearLayout">
       <androidx.appcompat.widget.AppCompatImageView
           android:id="@+id/vIcon"
           android:layout_width="100dp"
           android:layout_height="100dp"/>

       <TextView
           android:id="@+id/vTitle"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:textSize="24sp"
           android:textColor="@android:color/black"
           android:layout_marginStart="16dp"
           android:layout_marginEnd="16dp"/>
   </LinearLayout>

</merge>

Затем создадим MenuView и заинфлейтим в нее наш layout:

class MenuView @JvmOverloads constructor(
  context: Context,
  attrs: AttributeSet? = null,
  defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

  var titleText: String = ""
     set(value) {
        field = value
        vTitle.text = value
     }

  @DrawableRes
  var iconId: Int = 0
     set(value) {
        field = value
        vIcon.setImageResource(value)
     }

  init {
     View.inflate(context, R.layout.view_menu, this)
     isFocusable = true
     isFocusableInTouchMode = true
  }
}

И, наконец, перепишем презентер. Теперь он будет связывать данные из элемента меню в MenuView:

class MenuPresenter : Presenter() {

  override fun onCreateViewHolder(viewGroup: ViewGroup): Presenter.ViewHolder =
     ViewHolder(createView(viewGroup))

  private fun createView(viewGroup: ViewGroup) =
     viewGroup
        .context
        .let { context ->
           MenuView(context)
              .apply {
                 setBackgroundColor(context.getColorRes(R.color.menu_item_background))
              }
        }

  override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) =
     (viewHolder as ViewHolder)
        .bind(item as MenuItem)

  override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) =
     (viewHolder as ViewHolder)
        .unbind()

  private inner class ViewHolder(view: MenuView) : Presenter.ViewHolder(view) {

     fun bind(item: MenuItem) = with(view as MenuView) {
        item.run {
           titleText = title
           iconId = icon
        }
     }

     fun unbind() = with(view as MenuView) {
        titleText = ""
        iconId = 0
     }
  }
}

Похоже, но при нажатии на карточку отсутствует ripple-эффект. Создадим ripple drawable:

<ripple xmlns:android="http://schemas.android.com/apk/res/android"
   android:color="?android:attr/colorControlHighlight" >
</ripple>

И установим его как foreground MenuView:

foreground = context.getDrawableRes(R.drawable.menu_card_ripple)

Теперь при нажатии на карточку можно увидеть ripple-эффект:

Появилась другая проблема. RippleDrawable обрабатывает состояние focused и накладывает на view полупрозрачный бэкграунд. Чтобы этот эффект было лучше видно, изменим цвет карточки на темный.

Как видно, выделенная карточка становится светлее.Для решения этой проблемы отфильтруем состояние focused у MenuView, переопределив следующий метод:

override fun onCreateDrawableState(extraSpace: Int): IntArray {
  val states = super.onCreateDrawableState(extraSpace)
  return states.filter { it != android.R.attr.state_focused }.toIntArray()
}

Мы избавились от искажения цвета карточки в выделенном состоянии и сохранили ripple.

Таким образом можно создавать карточки контента, которые нельзя сделать при помощи ImageCardView. Но это еще не все: я бы хотел улучшить получившуюся карточку и сделать у нее поддержку функционала, который предоставляет базовая реализация карточек BaseCardView. Давайте добавим на нашу карточку небольшое описание, которое будет появляться при переходе в состояние selected. Для этого создадим еще один layout:

<merge xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   xmlns:lb="http://schemas.android.com/apk/res-auto"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:orientation="vertical"
   android:gravity="center"
   tools:parentTag="android.widget.FrameLayout">

   <LinearLayout
       android:layout_width="@dimen/menu_item_width"
       android:layout_height="@dimen/menu_item_height"
       android:orientation="vertical"
       android:gravity="center"
       tools:parentTag="android.widget.LinearLayout"
       lb:layout_viewType="main">
       <androidx.appcompat.widget.AppCompatImageView
           android:id="@+id/vIcon"
           android:layout_width="100dp"
           android:layout_height="100dp"/>

       <TextView
           android:id="@+id/vTitle"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:textSize="24sp"
           android:textColor="@android:color/black"
           android:layout_marginStart="16dp"
           android:layout_marginEnd="16dp"/>
   </LinearLayout>

   <TextView
       android:id="@+id/vDescription"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:background="@color/menu_desc_background"
       android:paddingStart="16dp"
       android:paddingBottom="16dp"
       android:paddingTop="16dp"
       android:paddingEnd="16dp"
       lb:layout_viewType="info"/>

</merge>

Этот layout похож на предыдущий, за исключением того что здесь добавили дополнительный TextView для вывода описания и новый атрибут lb:layout_viewType. Это атрибут BaseCardView, с помощью которого он находит элементы, которыми умеет управлять. У тега существует 3 значения: main — основная область карточки, info — информационная область и extra — дополнительная область.

Создадим новую view, которая будет наследником BaseCardView. Так мы добавим базовые функции карточек:

class MenuCardView @JvmOverloads constructor(
  context: Context,
  attrs: AttributeSet? = null,
  defStyleAttr: Int = 0
) : BaseCardView(ContextThemeWrapper(context, R.style.MenuCardViewStyle), attrs, defStyleAttr) {

  var titleText: String = ""
     set(value) {
        field = value
        vTitle.text = value
     }

  var descriptionText: String = ""
     set(value) {
        field = value
        vDescription.text = value
     }

  @DrawableRes
  var iconId: Int = 0
     set(value) {
        field = value
        vIcon.setImageResource(value)
     }

  init {
     View.inflate(context, R.layout.view_menu_card, this)
     isFocusable = true
     isFocusableInTouchMode = true
  }
}

Теперь наследуем тему для view. Установим для свойства cardType значение infoOver — это означает, что информационная область (TextView с описанием) будет находиться поверх основной. А для свойства infoVisibility установим значение selected — это означает, что информационная область будет видна только в момент, когда карточка находится в состоянии selected.

<style name="MenuCardViewStyle" parent="Widget.Leanback.BaseCardViewStyle">
   <item name="cardType">infoOver</item>
   <item name="infoVisibility">selected</item>
</style>

Так мы получили кастомную карточку, поддерживающую базовые фичи карточек из Leanback.

Вывод

Leanback предоставляет достаточно гибко настраиваемый вариант карточки, а именно ImageCardView. При необходимости можно достаточно просто создать свою карточку контента, а для того чтобы она имела базовый функционал карточек, ее родителем должна выступать BaseCardView.

Если вам понадобится проект, который создавался в статье, то его можно скачать на GitHub.

Курс

Профессия
Android-разработчик


Освойте тонкости создания приложений для самой популярной мобильной платформы: изучите архитектурные подходы, популярные библиотеки, Unit- и UI-тестирование. Вместе мы создадим приложение и выложим его в Google Play, даже если до этого вы никогда не программировали!

Хочешь получать крутые статьи по программированию?
Подпишись на рассылку Skillbox