android_tv.md 40 KB

Android TV

Содрано отсюда, отсюда и отсюда

Первая статья довольно древняя (2016 год), попробуем реализовать в 2022

Данная лекция познакомит вас с разработкой простого приложения для Android TV.

Так как интерфейс приложений для телефонов и Android TV имеет существенные различия, то мы должны создать интерфейс приложения, подходящий для взаимодействия на TV. Например, нам следует создавать приложения с которыми можно взаимодействовать, используя только клавиши . В реализации такого интерфейса нам может помочь библиотека LeanbackSupport, позволяющая вполне легко создавать UI, который будет удобен при работе с приложениями на Android TV.

Коротко о библиотеке Leanback

Библиотека Leanback представляет собой набор шаблонов экранов с различными функциональными особенностями. Есть экраны для отображения списков, карточек контента, диалогов и т. д. Эти экраны обрабатывают все пользовательские переходы между элементами и анимации, а также имеют довольно обширный функционал для построения простых приложений “из коробки”. Идеология данной библиотеки заключается в том, что все приложения на ее основе должны быть похожи в плане пользования. Не нужно думать, узнает ли пользователь о том что можно прокрутить вниз? Узнает, потому что он уже пользовался сотнями однотипных приложений.

Создание проекта в Android Studio

Запустив Android Studio, необходимо создать новый проект. При создании выбрать платформу TV и указать минимальную версию SDK. Android Studio предложит нам создать "Blank Activity", его и создадим (в оригинальной статье предлагают создать пустой проект без активности "No Activity" и руками добавлять классы, файлы разметки и фрагменты, но это долго, проще выкинуть лишнее из рабочего проекта)

Можем запустить и увидеть что проект работает и что-то показывает:

Рассмотрим структуру созданного проекта:

Манифест

Менять мы пока ничего не будем, просто ознакомимся с особенностями проекта для TV

  • По-умолчанию уже добавлено разрешение на работу с интернетом

    <uses-permission android:name="android.permission.INTERNET" />
    
  • Заданы требования к целевому устройству

    Может отсутствовать (не требуется) тачскрин:

    <uses-feature
        android:name="android.hardware.touchscreen"
        android:required="false" />
    

    Приложение должно запускаться только на Android TV. Если вы разрабатываете приложение не только для TV, то вам следует установить значение false:

    <uses-feature
        android:name="android.software.leanback"
        android:required="true" />
    
  • При объявлении Activity мы указываем в intent-filter в теге category, что Activity должна запускаться на Android TV.

    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
    
        <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
    </intent-filter>
    

MainActivity

Первым запускается как обычно MainActivity

class MainActivity : FragmentActivity() 
{
    override fun onCreate(savedInstanceState: Bundle?) 
    {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        if (savedInstanceState == null) {
            getSupportFragmentManager().beginTransaction()
                .replace(R.id.main_browse_fragment, MainFragment())
                .commitNow()
        }
    }
}

activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main_browse_fragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    tools:deviceIds="tv"
    tools:ignore="MergeRootFrame" />

По коду мы видим, что вёрстка приложени состоит из единственного фрагмента, содержимое для которого генерится в классе MainFragment.

MainFragment

Класс MainFragment наследуется от класса BrowseSupportFragment(), а про его структуру хорошо расписано во второй статье.

BrowseFragment — это фрагмент, предназначенный для создания экрана со списками элементов и заголовками. Его структура выглядит следующим образом:

Рассмотрим каждый из элементов:

  • TitleView - это контейнер с элементами. Он нужен для брендирования приложения (текст и логотип в правом верхнем углу), а также для добавления кнопки поиска. Чтобы кнопка поиска была видимой, ей необходимо установить слушателя. Это можно сделать, вызвав метод setOnSearchClickedListener у фрагмента.

  • HeadersFragment & RowsFragment - HeadersFragment — список заголовков в левой части экрана и RowsFragment — контейнер для контента в правой части экрана. Эти фрагменты работают в связке, и BrowseFragment делегирует им отрисовку элементов своего адаптера.

Таким образом, в методе onActivityCreated класса MainFragment настраиваются вышеперечисленные контейнеры. Можем убедиться в этом закомментировав функции, которые что-то делают и посмотрев на результат:

override fun onActivityCreated(savedInstanceState: Bundle?) {
    Log.i(TAG, "onCreate")
    super.onActivityCreated(savedInstanceState)

    // prepareBackgroundManager()
    // setupUIElements()
    // loadRows()
    // setupEventListeners()
}

Метод prepareBackgroundManager

Инициализирует lateinit свойства класса и пока ничего не показывает

Метод setupUIElements

Устанавливает заголовок в правом верхнем углу и цвет для левой панели

Метод loadRows

Формирует всё содержимое, разберёмся с ним подробнее:

  1. val list = MovieList.list - Получаем список фильмов

    Свойство list класса MovieList возвращает массив объектов Movie, реализацию можно не разбирать, мы в реальном проекте всё-равно этот список будем получать динамически из АПИ.

  2. val rowsAdapter = ArrayObjectAdapter(ListRowPresenter()) - Создаётся стандартный адаптер ArrayObjectAdapter использующий стандартный класс-представление ListRowPresenter для вывода "строки". "Строка" (ListRow - строка списка), отображаемая этим представлением, выводит во фрагменте заголовков (HeadersFragment - левая часть экрана) заголовок, а во фрагменте строк (RowsFragment - правая часть) содержимое.

  3. val cardPresenter = CardPresenter() - создание самописанного (реализующего абстрактный класс) экземпляра представления карточки фильма

  4. Цикл перебора категорий (for (i in 0 until NUM_ROWS))

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

    if (i != 0) {
        Collections.shuffle(list)
    }
    

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

    Далее создаётся адаптер для содержимого, которое будет отображаться в RowsFragment (правая часть экрана) и заполняется фильмами из списка:

    val listRowAdapter = ArrayObjectAdapter(cardPresenter)
    for (j in 0 until NUM_COLS) {
        listRowAdapter.add(list[j % 5])
    }
    

    В конце цикла создаётся заголовок для категории. Заголовок вместе со списком фильмов добавляется в адаптер "строк" rowsAdapter:

    val header = HeaderItem(
        i.toLong(), 
        MovieList.MOVIE_CATEGORY[i])
    
    rowsAdapter.add(
        ListRow(
            header, 
            listRowAdapter))
    

    В итоге структура выглядит примерно так:

    ListRowPresenter это стандартный класс библиотеки Leanback, отвечающий за размещение заголовка и содержимого по разным фрагментам (Headers и Rows). CardPresenter класс, отвечающий за отображение отдельного элемента (в правом фрагменте).

  5. Добавление строки с настройками

    Строка находится в общем адаптере (вертикальный список), но реализация элементов списка у неё другая:

    // формируем заголовок
    val gridHeader = HeaderItem(
        NUM_ROWS.toLong(), 
        "PREFERENCES")
    
    // здесь создаётся представление для элемента настроек
    val mGridPresenter = GridItemPresenter()
    
    // на его основе делается адаптер для строки элементов
    val gridRowAdapter = ArrayObjectAdapter(mGridPresenter)
    
    // и в эту строку добавляются пункты меню настроек
    gridRowAdapter.add(
        resources.getString(R.string.grid_view))
    gridRowAdapter.add(
        getString(R.string.error_fragment))
    gridRowAdapter.add(
        resources.getString(R.string.personal_settings))
    

    В итоге в основной адаптер добавляется строка с настройками

    rowsAdapter.add(
        ListRow(gridHeader, gridRowAdapter))
    

    И готовый адаптер назначается адаптеру нашего класса (его нет в нашей реализации, он объявлен в родительском классе)

    adapter = rowsAdapter
    

Метод setupEventListeners

Настраивает события, которые будет обрабатывать приложение:

setOnSearchClickedListener {
    Toast.makeText(
            activity!!, 
            "Implement your own in-app search",
            Toast.LENGTH_LONG)
        .show()
}

Назначение этого события включает иконку поиска. Реализации поиска пока никакой нет.

onItemViewClickedListener = ItemViewClickedListener()

onItemViewClickedListener - обработчик события клика по карточке фильма

onItemViewSelectedListener = ItemViewSelectedListener()

onItemViewSelectedListener - обработчик клика по элементу вертикального списка (категории)

Модификация под свои нужды

Загрузочный экран

Не знаю, будет ли это на демо-экзамене, но для демонстрации работы с обычными activity добавим загрузочный экран:

  1. Добавьте пустую активность (в контекстном меню пакета New -> Activity -> Empty Activity)

  2. В манифесте перенесите тег <intent-filter> из активности .MainActivity в .LaunchActivity (я ещё скопировал атрибут android:screenOrientation="landscape")

  3. В drawable ресурсы добавьте картинку для заставки и в файл разметки добавьте ImageView с этой картинкой

  4. В классе LaunchActivity:

    • поменяйте родителя, вместо AppCompatActivity оставьте просто Activity (AppCompatActivity не реализован в Android TV)

    • Реализуйте таймер и переход на главное окно:

      class LaunchActivity : Activity() {
          lateinit var that: Activity
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContentView(R.layout.activity_launch)
              that = this
              Timer().schedule(3000L){
                  that.runOnUiThread {
                      startActivity(Intent(that, MainActivity::class.java))
                  }
              }
          }
      }
      

      Тут мне лень было писать метод для обработки события таймера, поэтому контекст (this) я сохранил в отдельную переменную (внутри лямбда-функции таймера контекст другой)

Всё замечательно работает!!!

Получение списка фильмов с сервера

Что-бы не терять время просто так, поместим этот код в загрузочную активность (заодно проверим как тут работает класс Application)

В классе приложения (MyApp) описываем переменную val movieList = ArrayList<Movie>(), в которую будем записывать информацию о фильмах (Класс Movie я пока оставил как есть).

override fun onCreate(savedInstanceState: Bundle?) 
{
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_launch)
    app = applicationContext as MyApp
    that = this
    Timer().schedule(3000L){
        that.runOnUiThread {
            startActivity(Intent(that, MainActivity::class.java))
        }
    }
    // запрос списка фильмов вынесен в отдельный метод
    getMovieList()
}

private fun getMovieList() {
    Http.call("http://cinema.kolei.ru/movies?filter=new"){ 
        response, error ->
        try {
            if (error != null) throw error
            if (!response!!.isSuccessful) 
                throw Exception(response.message)

            var json = JSONArray(response.body!!.string())

            // работу с классом приложения на всякий случай тоже заворачиваю в UI поток
            runOnUiThread {
                app.movieList.clear()
                for (i in 0 until json.length()){
                    val item = json.getJSONObject(i)
                    app.movieList.add(Movie(
                        id = item.getLong("movieId"),
                        title = item.getString("name"),
                        description = item.getString("description"),
                        cardImageUrl = "http://cinema.kolei.ru/up/images/${item.getString("poster")}"
                    ))
                }
            }
        } catch (e: Exception) {
            // любую ошибку показываем на экране
            runOnUiThread {
                AlertDialog.Builder(this)
                    .setTitle("Ошибка")
                    .setMessage(e.message)
                    .setPositiveButton("OK", null)
                    .create()
                    .show()
            }
        }
    }
}

В классе MainFragment указатель на app получаем через ссылку на текущую активность (я упоминал в прошлой лекции, что фрагмент не может существовать сам по себе, а работает в контексте какой-то активности)

override fun onActivityCreated(savedInstanceState: Bundle?) 
{
    super.onActivityCreated(savedInstanceState)
    app = activity?.application as MyApp

И в методе loadRows задаём в качестве списка фильмов тот, который получили с сервера

private fun loadRows() {
    val list = app.movieList  //MovieList.list

Запускаем проект и видим, что данные получены и отображаются верно (нужно только поправить вёрстку) и знакомые нам механизмы работы с классом приложения и сетевые запросы работают как надо:

Группировка по категориям

В АПИ в информацию о фильме я добавил категорию.

Добавим поле для категории в класс Movie

var category: String? = null

Не забываем заполнить его при получении данных:

app.movieList.add(Movie(
    id = item.getLong("movieId"),
    title = item.getString("name"),
    description = item.getString("description"),
    cardImageUrl = "http://cinema.kolei.ru/up/images/${item.getString("poster")}",
    category = item.getString("category")
))

В методе loadRows, перед формированием данных для отображения, получаем список уникальных названий категорий:

val categoriesList = list.map{m->m.category}.distinct()

С методом distinct вы уже знакомы, а метод map преобразует содержимое массива. В нашем случае мы выбираем только название категорий (в итоге получается массив строк).

Теперь перебираем список категорий, создавая для каждой свой listRowAdapter. Далее во вложенном цикле заполняем этот адаптер подходящими по категории фильмами. После заполнения одной категории заголовок (категория) и содержимое этой категории (listRowAdapter) записываются в общий

for (i in categoriesList.indices){
    val listRowAdapter = ArrayObjectAdapter(cardPresenter)

    // формируем элементы строки, фильтруя фильмы по категории
    for (j in list.indices) {
        if (list[j].category != null && list[j].category == categoriesList[i]) {
            listRowAdapter.add(list[j])
        }
    }

    // сформированную строку с заголовком пишем в rowsAdapter
    val header = HeaderItem(categoriesList[i])
    rowsAdapter.add(ListRow(header, listRowAdapter))
}

Настройка вёрстки карточки фильма

Представление для карточки фильма реализовано в файле CardPresenter.kt.

Класс CardPresenter наследуется от абстрактного класса Presenter и должен реализовать три метода: onCreateViewHolder, onBindViewHolder, onUnbindViewHolder. Всё это похоже на знакомый нам адаптер для RecyclerView.

  1. Метод onCreateViewHolder

    Создаёт визуальное представление элемента. Можно реализовать своё представление и мы это позже рассмотрим, а можно использовать встроенный в библиотеку класс ImageCardView. В CardPresenter реализован второй вариант, разберём его подробнее:

    override fun onCreateViewHolder(parent: ViewGroup): Presenter.ViewHolder 
    {
        // цвет фона по-умолчанию, если по ТЗ его менять не нужно, то и не трогаем
        // в принципе в этом представлении мы его и не увидим - у нас на всю карточку рисуется изображение
        sDefaultBackgroundColor = ContextCompat
            .getColor(
                parent.context, 
                R.color.default_background)
    
        // аналогично для выбранной (активной) карточки
        sSelectedBackgroundColor =  ContextCompat
            .getColor(
                parent.context, 
                R.color.selected_background)
    
        // изображение, которое будет показано, если не найдёт нужную картинку        
        mDefaultCardImage = ContextCompat
            .getDrawable(
                parent.context, 
                // в ресурсах должна быть соответсвующая картинка 
                R.drawable.movie)   
    
    
        // создаётся экземпляр класса ImageCardView с переопределением
        // метода setSelected
        val cardView = object : ImageCardView(parent.context) {
            override fun setSelected(selected: Boolean)
            {
                updateCardBackgroundColor(this, selected)
                super.setSelected(selected)
            }
        }
    
        // но можно и проще, если нам не надо менять цвет фона
        // val cardView = ImageCardView(parent.context)
    
    
        cardView.isFocusable = true
        cardView.isFocusableInTouchMode = true
        updateCardBackgroundColor(cardView, false)
    
        // возвращаем настроенный экземпляр cardView
        return Presenter.ViewHolder(cardView)
    }
    
  2. Метод onBindViewHolder

    Этот метод привязывает данные текущего элемента списка к его визуальному представлению. Первый параметр это результат метода onCreateViewHolder, второй - очередной элемент списка. Метод универсальный, поэтому тип элемента Any, мы при реализации приводим к тому типу данных, который использовали при формировании списка (передавали в методе add)

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

    • titleText - название
    • contentText - содержание
    • badgeImage - иконка (в этом примере не используется, но позже мы сюда что-нибудь добавим)
    • mainImage или mainImageView - изображение

      override fun onBindViewHolder(
      viewHolder: Presenter.ViewHolder, 
      item: Any) 
      {
      // создаём переменные, приведённые к нужным типам
      val movie = item as Movie
      val cardView = viewHolder.view as ImageCardView
      
      // если задан URL картинки, то заполняем данные карточки
      if (movie.cardImageUrl != null) {
          // задаём название и содержание
          cardView.titleText = movie.title
          cardView.contentText = movie.description
      
          // задаём размеры изображения
          cardView.setMainImageDimensions(
              CARD_WIDTH, 
              CARD_HEIGHT)
      
          // загружаем картинку cardImageUrl в элемент разметки mainImageView
          // если при загрузке картинки возникнет ошибка, то выведется изображение по-умолчанию
          Glide.with(viewHolder.view.context)
              .load(movie.cardImageUrl)
              .centerCrop()
              .error(mDefaultCardImage)
              .into(cardView.mainImageView)
      }
      }
      
  3. Метод onUnbindViewHolder

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

    override fun onUnbindViewHolder(
        viewHolder: Presenter.ViewHolder) 
    {
        val cardView = viewHolder.view as ImageCardView
        cardView.badgeImage = null
        cardView.mainImage = null
    }
    

Доработка

В примере не продемонстрирована установка иконки у карточки. Иконка задаётся drawable ресурсом, т.е. может быть либо изображением, либо фигурой.

У нас в АПИ у класса Movie есть возрастные ограничения, добавим их отображение иконкой:

  1. Добавьте изображения для иконок для всех доступных возрастов: [0,6,12,16,18] (так как название ресурса одновременно является названием переменной, а переменная должна начинаться с буквы или подчёркивания, то добавьте к имени файла префикс age: age0.png, age6.png, ...)

  2. Добавьте свойство age: String? = null в класс Movie и заполняйте его при получении списка фильмов.

  3. В метод onBindViewHolder добавьте установку свойства badgeImage:

    cardView.badgeImage  = cardView.context.getDrawable(  
        cardView.context.resources.getIdentifier(
            "age${movie.age}", 
            "drawable", 
            cardView.context.getPackageName()))
    

    Так как заранее не известно, какое название ресурса понадобится мы ищем ресурс не по id, а по имени. Метод getIdentifier принимает три параметра:

    • название ресурса
    • тип ресурса
    • название пакета

    Ну и как видно по коду выполняться всё это должно в нужном контексте (по-умолчанию это this - указатель на класс активности, но этот код работает в контексте класса CardPresenter, поэтому контекст приходится вытаскивать из View)

    В итоге в карточках должна появиться иконка:

    По-умолчанию иконка выводится в справа, но можно с помощью стилей поместить её слева:

    • в файл values/themes.xml добавьте стиль, переопределяющий стандартный стиль Widget.Leanback.ImageCardViewStyle, в котором переопределяем значение для элемента lbImageCardViewType (допускаемые значения: Title, Content, IconOnRight, IconOnLeft, ImageOnly):

      <style 
          name="ImageCardViewStyle" 
          parent="Widget.Leanback.ImageCardViewStyle"
      >
          <item 
              name="lbImageCardViewType"
          >
              IconOnLeft|Title|Content
          </item>
      </style>
      
    • затем добавляем тему ImageCardTheme, переопределяющую стандартную тему Theme.Leanback, в которой задаём свойство imageCardViewStyle:

      <style 
          name="ImageCardTheme" 
          parent="Theme.Leanback"
      >
          <item 
              name="imageCardViewStyle"
          >
              @style/ImageCardViewStyle
          </item>
      </style>
      
    • в методе CardPresent.onCreateViewHolder меняем параметры конструктора ImageCardView:

      // val cardView = ImageCardView(parent.context)
      val cardView = ImageCardView(  
          ContextThemeWrapper(
              parent.context, 
              R.style.ImageCardTheme))
      

В Котлин-е есть оператор with, который позволяет выполнять блок кода в заданном контексте, причем его можно использовать как реализацию функции:

>override fun onBindViewHolder(
>    viewHolder: Presenter.ViewHolder, item: Any
>) = with(viewHolder.view as ImageCardView) 
>{
>    val movie = item as Movie
>
>    if (movie.cardImageUrl != null) {
>        titleText = movie.title
>        contentText = movie.description
>
>        badgeImage  = context.getDrawable(
>            context.resources.getIdentifier(
>                "age18",
>                "drawable", 
>                context.getPackageName()))
>
>        setMainImageDimensions(
>            CARD_WIDTH, 
>            CARD_HEIGHT)
>
>        Glide.with(context)
>            .load(movie.cardImageUrl)
>            .centerCrop()
>            .error(mDefaultCardImage)
>            .into(mainImageView)
>    }
>}
>```

На этом варианты настроек (**ImageCardView**) исчерпаны, если нам нужно поменять элементы местами или добавить кнопку, то придётся рисовать свой презентер.

### Реализация самописанной (custom) разметки карточки 

Собственно такая разметка используется при выводе пунктов меню настройки (**GridItemPresenter**):

kt private inner class GridItemPresenter : Presenter() {

override fun onCreateViewHolder(
    parent: ViewGroup
): Presenter.ViewHolder 
{
    // в качестве визуального элемента используется 
    // обычный TextView
    val view = TextView(parent.context)
    view.layoutParams = ViewGroup.LayoutParams(
        GRID_ITEM_WIDTH, 
        GRID_ITEM_HEIGHT)

    view.isFocusable = true
    view.isFocusableInTouchMode = true
    view.setBackgroundColor(
        ContextCompat.getColor(
            activity!!, 
            R.color.default_background))

    view.setTextColor(Color.WHITE)
    view.gravity = Gravity.CENTER

    return Presenter.ViewHolder(view)
}

override fun onBindViewHolder(
    viewHolder: Presenter.ViewHolder, 
    item: Any) 
{
    // элементы списка обычные строки
    (viewHolder.view as TextView).text = item as String
}

override fun onUnbindViewHolder(
    viewHolder: Presenter.ViewHolder) {}

}


Это представление простое, но даёт нам понимание, что в качестве представления может быть любой визуальный элемент. 

Сделаем свою карточку: **CardView** со скруглёнными углами, картинка, текст и переключатель (**Switch**) с функцией обратного вызова:

1. Нарисуем разметку в `layout/card_item.xml`

    ```xml
    <?xml version="1.0" encoding="utf-8"?>
    <androidx.cardview.widget.CardView
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="150dp"
        android:layout_height="300dp"
        app:cardCornerRadius="20dp"
        >
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">
            <ImageView
                android:id="@+id/logoImageView"
                android:layout_width="match_parent"
                android:adjustViewBounds="true"
                android:scaleType="centerCrop"
                android:focusable="false"
                android:layout_height="200dp"/>
            <TextView
                android:id="@+id/captionTextView"
                android:layout_margin="10dp"
                android:text="caption"
                android:focusable="false"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"/>
            <Switch
                android:id="@+id/stateSwitch"
                android:layout_margin="10dp"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:focusedByDefault="true"
                android:text="Состояние" />
        </LinearLayout>
    </androidx.cardview.widget.CardView>
    ```
2. Создадим класс **CustomPresenter**:

    ```kt
    class CustomPresenter : Presenter() {
        // в классе описываем доступные визуальные элементы
        lateinit var logo: ImageView
        lateinit var caption: TextView
        lateinit var state: Switch
        private var mDefaultCardImage: Drawable? = null

        // переменная для задания callback-функции
        private var switchListener: ((Movie, Boolean) -> Unit)? = null

        // метод, устанавливающий callback-функцию
        fun setOnSwitchListener(
            switchListener: (Movie, Boolean) -> Unit) 
        {
            this.switchListener = switchListener
        }

        @SuppressLint("MissingInflatedId")
        override fun onCreateViewHolder(
            parent: ViewGroup): ViewHolder 
        {
            // получаем указатель на layout
            val view = LayoutInflater
                .from(parent?.context)
                .inflate(
                    R.layout.card_item, 
                    parent, 
                    false)

            // получаем указатели на визуальные элементы
            logo = view.findViewById(R.id.logoImageView)
            caption = view.findViewById(R.id.captionTextView)
            state = view.findViewById(R.id.stateSwitch)
            mDefaultCardImage = ContextCompat
                .getDrawable(parent.context, R.drawable.movie)

            return Presenter.ViewHolder(view)
        }

        override fun onBindViewHolder(
            viewHolder: ViewHolder?, 
            item: Any?) 
        {
            val movie = item as Movie

            caption.text = movie.title

            // при изменении состояния переключателя 
            // пробросим событие наружу
            state.setOnCheckedChangeListener { 
                _, b ->
                switchListener?.invoke(item, b)
            }

            // рисуем логотип
            Glide.with(viewHolder!!.view.context)
                .load(movie.cardImageUrl)
                .centerCrop()
                .error(mDefaultCardImage)
                .into(logo)
        }

        override fun onUnbindViewHolder(
            viewHolder: ViewHolder?) {}
    }
    ```

3. И в методе *loadRows* поменяем класс для представления:

    ```kt
    val cardPresenter = CustomPresenter()  // CardPresenter()
    cardPresenter.setOnSwitchListener { movie, b ->
        Log.w("switch", "${movie.title} to ${b}")
    }
    ```

Получится что-то такое:

![](../img/tv_13.png)

После реализации своего презентера сломался клик по карточке (с переходом на окно детальной информации). Как починить по-правильному я пока не сообразил, сделаем пока "криво":

В методе *onItemClicked* класса **ItemViewClickedListener** поменяем получение ссылки на картинку:

kt // костыль, нарушающий инкапсуляцию val logo = itemViewHolder.view

.findViewById<ImageView>(
    R.id.logoImageView)

val bundle = ActivityOptionsCompat

.makeSceneTransitionAnimation(
    activity!!,
    logo,
    DetailsActivity.SHARED_ELEMENT_NAME
)

```