HTML Diff
0 added 0 removed
Original 2026-01-01
Modified 2026-02-21
1 <p><a>#Руководства</a></p>
1 <p><a>#Руководства</a></p>
2 <ul><li>9 июн 2022</li>
2 <ul><li>9 июн 2022</li>
3 <li>0</li>
3 <li>0</li>
4 </ul><p>Подробный разбор возможностей BrowseFragment для разработки под Android TV - на примере приложения онлайн-кинотеатра.</p>
4 </ul><p>Подробный разбор возможностей BrowseFragment для разработки под Android TV - на примере приложения онлайн-кинотеатра.</p>
5 <p>Иллюстрация: Merry Mary для Skillbox Media</p>
5 <p>Иллюстрация: Merry Mary для Skillbox Media</p>
6 <p>Android-разработчик в аутсорс-продакшене FINCH. Программирует на Kotlin и Java. Любит кодить и отдыхать на пляже.</p>
6 <p>Android-разработчик в аутсорс-продакшене FINCH. Программирует на Kotlin и Java. Любит кодить и отдыхать на пляже.</p>
7 <p>В интернете, помимо официальной документации от Google и нескольких HelloWorld-статей на русском языке, мало информации о работе с библиотекой Leanback. Поэтому я решил рассказать об опыте нашей компании.</p>
7 <p>В интернете, помимо официальной документации от Google и нескольких HelloWorld-статей на русском языке, мало информации о работе с библиотекой Leanback. Поэтому я решил рассказать об опыте нашей компании.</p>
8 <p>Мы рассмотрим возможности BrowseFragment, который, по задумке Google, должен быть основным экраном приложения. В нашем случае это было приложение для онлайн-кинотеатра крупного медиахолдинга.</p>
8 <p>Мы рассмотрим возможности BrowseFragment, который, по задумке Google, должен быть основным экраном приложения. В нашем случае это было приложение для онлайн-кинотеатра крупного медиахолдинга.</p>
9 <p>Опишу инструменты, с помощью которых мы собрали что-то подобное:</p>
9 <p>Опишу инструменты, с помощью которых мы собрали что-то подобное:</p>
10 <em>Скриншот: Skillbox Media</em><p>В статье я расскажу про:</p>
10 <em>Скриншот: Skillbox Media</em><p>В статье я расскажу про:</p>
11 <ul><li><a>BrowseFragment</a>и его составные элементы: TitleView, HeadersFragment &amp; RowsFragment.</li>
11 <ul><li><a>BrowseFragment</a>и его составные элементы: TitleView, HeadersFragment &amp; RowsFragment.</li>
12 <li>Настройку контейнера<a>TitleView</a><a>,</a>создание кастомных вариантов.</li>
12 <li>Настройку контейнера<a>TitleView</a><a>,</a>создание кастомных вариантов.</li>
13 <li>Базовую настройку<a>HeadersFragment и RowsFragment</a>.</li>
13 <li>Базовую настройку<a>HeadersFragment и RowsFragment</a>.</li>
14 <li><a>Кастомизацию HeadersFragment</a> - изменение внешнего вида всех заголовков.</li>
14 <li><a>Кастомизацию HeadersFragment</a> - изменение внешнего вида всех заголовков.</li>
15 <li><a>Кастомизацию RowsFragment</a>: работу с GridListRow, создание сеток, превращение RowsFragment в отдельный экран,<a>комбинирование элементов</a>разного типа в одной строке.</li>
15 <li><a>Кастомизацию RowsFragment</a>: работу с GridListRow, создание сеток, превращение RowsFragment в отдельный экран,<a>комбинирование элементов</a>разного типа в одной строке.</li>
16 </ul><p>BrowseFragment - это фрагмент, предназначенный для создания экрана со списками элементов и заголовками. Его структура выглядит следующим образом:</p>
16 </ul><p>BrowseFragment - это фрагмент, предназначенный для создания экрана со списками элементов и заголовками. Его структура выглядит следующим образом:</p>
17 <em>Изображение: Skillbox Media</em><p>Рассмотрим каждый из элементов.</p>
17 <em>Изображение: Skillbox Media</em><p>Рассмотрим каждый из элементов.</p>
18 <em>Скриншот: Skillbox Media</em><p>TitleView - это контейнер с элементами. Он нужен для брендирования приложения<em>TextView</em>и <em>ImageView</em>, а также для добавления кнопки поиска<em>SearchOrbView</em>из коробки. Чтобы кнопка поиска была видимой, ей необходимо установить слушателя. Это можно сделать, вызвав<em>setOnSearchClickedListener {//ваш код}</em>у фрагмента.</p>
18 <em>Скриншот: Skillbox Media</em><p>TitleView - это контейнер с элементами. Он нужен для брендирования приложения<em>TextView</em>и <em>ImageView</em>, а также для добавления кнопки поиска<em>SearchOrbView</em>из коробки. Чтобы кнопка поиска была видимой, ей необходимо установить слушателя. Это можно сделать, вызвав<em>setOnSearchClickedListener {//ваш код}</em>у фрагмента.</p>
19 <p>Настроить цветовую схему кнопки можно несколькими способами:</p>
19 <p>Настроить цветовую схему кнопки можно несколькими способами:</p>
20 <ul><li>Установив<em>searchAffordanceColor = context.getColorRes (R.color.search_opaque)</em>При таком подходе изменится только цвет круга.</li>
20 <ul><li>Установив<em>searchAffordanceColor = context.getColorRes (R.color.search_opaque)</em>При таком подходе изменится только цвет круга.</li>
21 <li>Установив<em>searchAffordanceColors = SearchOrbView.Colors (context.getColorRes (R.color.search_opaque),</em><em>context.getColorRes (R.color.search_opaque_bright),</em><em>context.getColorRes (R.color.search_opaque_icon)</em><em>)</em></li>
21 <li>Установив<em>searchAffordanceColors = SearchOrbView.Colors (context.getColorRes (R.color.search_opaque),</em><em>context.getColorRes (R.color.search_opaque_bright),</em><em>context.getColorRes (R.color.search_opaque_icon)</em><em>)</em></li>
22 </ul><p><em>SearchOrbView.Colors</em>имеет перегруженный конструктор, который позволяет более гибко настроить кнопку поиска.</p>
22 </ul><p><em>SearchOrbView.Colors</em>имеет перегруженный конструктор, который позволяет более гибко настроить кнопку поиска.</p>
23 <ul><li>Colors (@ColorInt int color) - установит цвет круга.</li>
23 <ul><li>Colors (@ColorInt int color) - установит цвет круга.</li>
24 <li>Colors (@ColorInt int color, @ColorInt int brightColor) - установит цвет круга и цвет анимации круга.</li>
24 <li>Colors (@ColorInt int color, @ColorInt int brightColor) - установит цвет круга и цвет анимации круга.</li>
25 <li>Colors (@ColorInt int color, @ColorInt int brightColor, @ColorInt int iconColor) - установит цвет круга, цвет анимации круга и цвет иконки.</li>
25 <li>Colors (@ColorInt int color, @ColorInt int brightColor, @ColorInt int iconColor) - установит цвет круга, цвет анимации круга и цвет иконки.</li>
26 </ul><p>Под анимацией круга понимается эффект мерцания, вот такой:</p>
26 </ul><p>Под анимацией круга понимается эффект мерцания, вот такой:</p>
27 <p>В TitleView можно установить текстовый тайтл<em>title = "Finch"</em> или логотип:</p>
27 <p>В TitleView можно установить текстовый тайтл<em>title = "Finch"</em> или логотип:</p>
28 <p><em>badgeDrawable = ContextCompat.getDrawable (context, R.drawable.app_icon_your_company).</em></p>
28 <p><em>badgeDrawable = ContextCompat.getDrawable (context, R.drawable.app_icon_your_company).</em></p>
29 <p>Причём установить можно только один из этих элементов, и наибольший приоритет всегда у логотипа.</p>
29 <p>Причём установить можно только один из этих элементов, и наибольший приоритет всегда у логотипа.</p>
30 <p>Элементы настраиваются гибко - достаточно придерживаться рекомендаций Google. При необходимости можно создать кастомный TitleView. Например, если вы хотите, чтобы вместо кнопки поиска было текстовое поле, то это можно сделать в несколько шагов:</p>
30 <p>Элементы настраиваются гибко - достаточно придерживаться рекомендаций Google. При необходимости можно создать кастомный TitleView. Например, если вы хотите, чтобы вместо кнопки поиска было текстовое поле, то это можно сделать в несколько шагов:</p>
31 <p>1. Создать layout для нового TitleView:</p>
31 <p>1. Создать layout для нового TitleView:</p>
32 &lt;merge xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content"&gt; &lt;TextView android:id="@+id/vTitle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentStart="true" android:layout_marginStart="40dp" android:layout_marginEnd="24dp" android:textAllCaps="true" android:textSize="20sp" android:textColor="@color/search_opaque" android:visibility="visible" /&gt; &lt;/merge&gt;<p>2. Создать View, которая реализует<em>TitleViewAdapter.Provider,</em>и создать сам<em>TitleViewAdapter:</em></p>
32 &lt;merge xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content"&gt; &lt;TextView android:id="@+id/vTitle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentStart="true" android:layout_marginStart="40dp" android:layout_marginEnd="24dp" android:textAllCaps="true" android:textSize="20sp" android:textColor="@color/search_opaque" android:visibility="visible" /&gt; &lt;/merge&gt;<p>2. Создать View, которая реализует<em>TitleViewAdapter.Provider,</em>и создать сам<em>TitleViewAdapter:</em></p>
33 class CustomTitleView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr), TitleViewAdapter.Provider { private val titleViewAdapter = object : TitleViewAdapter() { override fun getSearchAffordanceView(): View? { return null } override fun setTitle(titleText: CharSequence?) { this@CustomTitleView.setTitle(titleText) } } init { View.inflate(context, R.layout.view_custom_title, this) } private fun setTitle(title: CharSequence?) { vTitle.text = title } override fun getTitleViewAdapter(): TitleViewAdapter { return titleViewAdapter } }<p>3. Создать ещё один layout, в котором будет всего один элемент - CustomTitleView<em>:</em></p>
33 class CustomTitleView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr), TitleViewAdapter.Provider { private val titleViewAdapter = object : TitleViewAdapter() { override fun getSearchAffordanceView(): View? { return null } override fun setTitle(titleText: CharSequence?) { this@CustomTitleView.setTitle(titleText) } } init { View.inflate(context, R.layout.view_custom_title, this) } private fun setTitle(title: CharSequence?) { vTitle.text = title } override fun getTitleViewAdapter(): TitleViewAdapter { return titleViewAdapter } }<p>3. Создать ещё один layout, в котором будет всего один элемент - CustomTitleView<em>:</em></p>
34 &lt;fm.finch.tv_test_project.extensions.browse.customTitle.CustomTitleView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/browse_title_group" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp"&gt; &lt;/fm.finch.tv_test_project.extensions.browse.customTitle.CustomTitleView&gt;<p>4. Затем создать style для активити:</p>
34 &lt;fm.finch.tv_test_project.extensions.browse.customTitle.CustomTitleView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/browse_title_group" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp"&gt; &lt;/fm.finch.tv_test_project.extensions.browse.customTitle.CustomTitleView&gt;<p>4. Затем создать style для активити:</p>
35 &lt;style name="BrowseCustomTitleTheme" parent="Leanback.CustomTitle" /&gt; &lt;style name="Leanback.CustomTitle" parent="@style/Theme.Leanback.Browse"&gt; &lt;item name="browseTitleViewLayout"&gt;@layout/title_view&lt;/item&gt; &lt;item name="browseRowsMarginTop"&gt;60dp&lt;/item&gt; &lt;/style&gt;<p>И установить её как тему нашей активити в манифесте:</p>
35 &lt;style name="BrowseCustomTitleTheme" parent="Leanback.CustomTitle" /&gt; &lt;style name="Leanback.CustomTitle" parent="@style/Theme.Leanback.Browse"&gt; &lt;item name="browseTitleViewLayout"&gt;@layout/title_view&lt;/item&gt; &lt;item name="browseRowsMarginTop"&gt;60dp&lt;/item&gt; &lt;/style&gt;<p>И установить её как тему нашей активити в манифесте:</p>
36 &lt;activity android:name=".MainActivity" android:theme="@style/BrowseCustomTitleTheme"&gt; &lt;intent-filter&gt; &lt;action android:name="android.intent.action.MAIN" /&gt; &lt;category android:name="android.intent.category.LEANBACK_LAUNCHER" /&gt; &lt;/intent-filter&gt; &lt;/activity&gt;<p>Вот и всёмолчанию обрабатывает<a>onBackPressed()</a>. Если, у нас теперь есть кастомная TitleView.</p>
36 &lt;activity android:name=".MainActivity" android:theme="@style/BrowseCustomTitleTheme"&gt; &lt;intent-filter&gt; &lt;action android:name="android.intent.action.MAIN" /&gt; &lt;category android:name="android.intent.category.LEANBACK_LAUNCHER" /&gt; &lt;/intent-filter&gt; &lt;/activity&gt;<p>Вот и всёмолчанию обрабатывает<a>onBackPressed()</a>. Если, у нас теперь есть кастомная TitleView.</p>
37 <p><a>BrowseSupportFragment</a> - это фрагмент, предназначенный для отрисовки элементов своего адаптера (<a>ObjectAdapter</a>) в виде строк вертикального списка. Визуально этот фрагмент делится на две части, на HeadersFragment - список заголовков в левой части экрана и RowsFragment - контейнер для контента в правой части экрана. Эти фрагменты работают в связке, и BrowseFragment делегирует им отрисовку элементов своего адаптера.</p>
37 <p><a>BrowseSupportFragment</a> - это фрагмент, предназначенный для отрисовки элементов своего адаптера (<a>ObjectAdapter</a>) в виде строк вертикального списка. Визуально этот фрагмент делится на две части, на HeadersFragment - список заголовков в левой части экрана и RowsFragment - контейнер для контента в правой части экрана. Эти фрагменты работают в связке, и BrowseFragment делегирует им отрисовку элементов своего адаптера.</p>
38 <p>Все элементы, передаваемые в адаптер BrowseFragment, должны быть подклассами<a>Row</a>, так как этот объект несёт информацию о заголовке и контенте для отображения.</p>
38 <p>Все элементы, передаваемые в адаптер BrowseFragment, должны быть подклассами<a>Row</a>, так как этот объект несёт информацию о заголовке и контенте для отображения.</p>
39 <p>HeadersFragment предназначен для отрисовки и взаимодействия с <a>HeaderItem</a>, которые являются частью Row, переданного в адаптер BrowseFragment.</p>
39 <p>HeadersFragment предназначен для отрисовки и взаимодействия с <a>HeaderItem</a>, которые являются частью Row, переданного в адаптер BrowseFragment.</p>
40 <p>RowsFragment предназначен для отрисовки и взаимодействия с элементами своего ObjectAdapter. Это не адаптер BrowseFragment, и он не является частью Row, переданного в BrowseFragment. В него передаются объекты, описывающие контент.</p>
40 <p>RowsFragment предназначен для отрисовки и взаимодействия с элементами своего ObjectAdapter. Это не адаптер BrowseFragment, и он не является частью Row, переданного в BrowseFragment. В него передаются объекты, описывающие контент.</p>
41 <p>Продемонстрирую сказанное на примере. Создадим <a>Presenter</a>, который будет передавать текст в TextView:</p>
41 <p>Продемонстрирую сказанное на примере. Создадим <a>Presenter</a>, который будет передавать текст в TextView:</p>
42 class TextPresenter : Presenter() { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { val view = TextView(parent.context) view.layoutParams = ViewGroup.LayoutParams(300, 200) view.isFocusable = true view.isFocusableInTouchMode = true view.setBackgroundColor( ContextCompat.getColor( parent.context, android.R.color.background_light ) ) view.gravity = Gravity.CENTER view.setTextColor(Color.BLACK) return Presenter.ViewHolder(view) } override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) { val textView = viewHolder.view as TextView val str = item as String textView.text = str } override fun onUnbindViewHolder(viewHolder: ViewHolder) { val textView = viewHolder.view as TextView textView.text = "" } }<p>Затем создадим все необходимые адаптеры, заполним их данными и передадим BrowseFragment для отображения данных:</p>
42 class TextPresenter : Presenter() { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { val view = TextView(parent.context) view.layoutParams = ViewGroup.LayoutParams(300, 200) view.isFocusable = true view.isFocusableInTouchMode = true view.setBackgroundColor( ContextCompat.getColor( parent.context, android.R.color.background_light ) ) view.gravity = Gravity.CENTER view.setTextColor(Color.BLACK) return Presenter.ViewHolder(view) } override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) { val textView = viewHolder.view as TextView val str = item as String textView.text = str } override fun onUnbindViewHolder(viewHolder: ViewHolder) { val textView = viewHolder.view as TextView textView.text = "" } }<p>Затем создадим все необходимые адаптеры, заполним их данными и передадим BrowseFragment для отображения данных:</p>
43 class MainFragment : BrowseSupportFragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) initView(savedInstanceState) } private fun initView(savedInstanceState: Bundle?) { headersState = HEADERS_HIDDEN title = "Finch" val browseAdapter = ArrayObjectAdapter(ListRowPresenter()) val rowsAdapter = ArrayObjectAdapter(TextPresenter()) rowsAdapter.add("Элемент 1") rowsAdapter.add("Элемент 2") rowsAdapter.add("Элемент 3") val firstHeader = HeaderItem("Заголовок 1") val secondHeader = HeaderItem("Заголовок 2") val thirdHeader = HeaderItem("Заголовок 3") browseAdapter.add(ListRow(firstHeader, rowsAdapter)) browseAdapter.add(ListRow(secondHeader, rowsAdapter)) browseAdapter.add(ListRow(thirdHeader, rowsAdapter)) adapter = browseAdapter } }<p>Каждому адаптеру передадим Presenter, который будет рисовать элементы:</p>
43 class MainFragment : BrowseSupportFragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) initView(savedInstanceState) } private fun initView(savedInstanceState: Bundle?) { headersState = HEADERS_HIDDEN title = "Finch" val browseAdapter = ArrayObjectAdapter(ListRowPresenter()) val rowsAdapter = ArrayObjectAdapter(TextPresenter()) rowsAdapter.add("Элемент 1") rowsAdapter.add("Элемент 2") rowsAdapter.add("Элемент 3") val firstHeader = HeaderItem("Заголовок 1") val secondHeader = HeaderItem("Заголовок 2") val thirdHeader = HeaderItem("Заголовок 3") browseAdapter.add(ListRow(firstHeader, rowsAdapter)) browseAdapter.add(ListRow(secondHeader, rowsAdapter)) browseAdapter.add(ListRow(thirdHeader, rowsAdapter)) adapter = browseAdapter } }<p>Каждому адаптеру передадим Presenter, который будет рисовать элементы:</p>
44 <ul><li>Для BrowseFragment здесь использована стандартная реализация<a>ListRowPresenter</a>. Этот Presenter умеет работать с ListRow и визуализирует ListRow, используя HorizontalGridView, помещённый в ListRowView.</li>
44 <ul><li>Для BrowseFragment здесь использована стандартная реализация<a>ListRowPresenter</a>. Этот Presenter умеет работать с ListRow и визуализирует ListRow, используя HorizontalGridView, помещённый в ListRowView.</li>
45 <li>Для адаптера RowsFragment мы использовали свою реализацию TextPresenter(), так как передаём в него элементы типа String и TextPresenter(). Он умеет их отображать.</li>
45 <li>Для адаптера RowsFragment мы использовали свою реализацию TextPresenter(), так как передаём в него элементы типа String и TextPresenter(). Он умеет их отображать.</li>
46 </ul><p>Теперь к каждому заголовку в левой половине экрана прикрепляется список в правой половине экрана:</p>
46 </ul><p>Теперь к каждому заголовку в левой половине экрана прикрепляется список в правой половине экрана:</p>
47 <em>Скриншот: Skillbox Media</em><p>Рассмотрим подробнее HeadersFragment.</p>
47 <em>Скриншот: Skillbox Media</em><p>Рассмотрим подробнее HeadersFragment.</p>
48 <p>В базовой реализации HeadersFragment сразу виден пользователю. Это поведение можно изменить с помощью метода<a>setHeadersState(int)</a>. К нему нужно обратиться во время вызова<a>onActivityCreated()</a>и передать одно из состояний:</p>
48 <p>В базовой реализации HeadersFragment сразу виден пользователю. Это поведение можно изменить с помощью метода<a>setHeadersState(int)</a>. К нему нужно обратиться во время вызова<a>onActivityCreated()</a>и передать одно из состояний:</p>
49 <ul><li>HEADERS_ENABLED - фрагмент виден пользователю.</li>
49 <ul><li>HEADERS_ENABLED - фрагмент виден пользователю.</li>
50 <li>HEADERS_HIDDEN - фрагмент свёрнут.</li>
50 <li>HEADERS_HIDDEN - фрагмент свёрнут.</li>
51 <li>HEADERS_DISABLED - фрагмент полностью скрыт с экрана.</li>
51 <li>HEADERS_DISABLED - фрагмент полностью скрыт с экрана.</li>
52 </ul><p>BrowseFragment по умолчанию обрабатывает<a>onBackPressed ()</a>. Если HeadersFragment свёрнут, то по нажатии пользователем кнопки "Назад" на пульте или джойстике он переходит в активное состояние и становится виден. Такое поведение отключается передачей параметра false в метод<a>setHeadersTransitionOnBackEnabled ()</a>.</p>
52 </ul><p>BrowseFragment по умолчанию обрабатывает<a>onBackPressed ()</a>. Если HeadersFragment свёрнут, то по нажатии пользователем кнопки "Назад" на пульте или джойстике он переходит в активное состояние и становится виден. Такое поведение отключается передачей параметра false в метод<a>setHeadersTransitionOnBackEnabled ()</a>.</p>
53 <p>Чтобы управлять состоянием HeadersFragment вручную, можно использовать метод<a>startHeadersTransition (boolean)</a>. В зависимости от входного параметра фрагмент заголовков либо будет показан (при передаче true), либо свёрнут (при передаче false).</p>
53 <p>Чтобы управлять состоянием HeadersFragment вручную, можно использовать метод<a>startHeadersTransition (boolean)</a>. В зависимости от входного параметра фрагмент заголовков либо будет показан (при передаче true), либо свёрнут (при передаче false).</p>
54 <p>Если есть необходимость прослушивать начало и конец анимации перехода HeadersFragment из свёрнутого состояния в активное и наоборот, то можно установить слушателя:<a>setBrowseTransitionListener(BrowseSupportFragment.BrowseTransitionListener)</a>.</p>
54 <p>Если есть необходимость прослушивать начало и конец анимации перехода HeadersFragment из свёрнутого состояния в активное и наоборот, то можно установить слушателя:<a>setBrowseTransitionListener(BrowseSupportFragment.BrowseTransitionListener)</a>.</p>
55 <p>Существует ещё несколько реализаций Row, которые оказывают влияние только на HeadersFragment:</p>
55 <p>Существует ещё несколько реализаций Row, которые оказывают влияние только на HeadersFragment:</p>
56 <ul><li>DividerRow - разделитель между заголовками.</li>
56 <ul><li>DividerRow - разделитель между заголовками.</li>
57 <li>SectionRow - подзаголовок.</li>
57 <li>SectionRow - подзаголовок.</li>
58 </ul><p>Изменим код следующим образом:</p>
58 </ul><p>Изменим код следующим образом:</p>
59 browseAdapter.add(ListRow(firstHeader, rowsAdapter)) browseAdapter.add(DividerRow()) browseAdapter.add(ListRow(secondHeader, rowsAdapter)) browseAdapter.add(DividerRow()) browseAdapter.add(ListRow(thirdHeader, rowsAdapter)) browseAdapter.add(SectionRow("Подзаголовок 1"))<p>Что получилось: между элементами HeadersFragment появились разделители, а у последнего элемента - подзаголовок.</p>
59 browseAdapter.add(ListRow(firstHeader, rowsAdapter)) browseAdapter.add(DividerRow()) browseAdapter.add(ListRow(secondHeader, rowsAdapter)) browseAdapter.add(DividerRow()) browseAdapter.add(ListRow(thirdHeader, rowsAdapter)) browseAdapter.add(SectionRow("Подзаголовок 1"))<p>Что получилось: между элементами HeadersFragment появились разделители, а у последнего элемента - подзаголовок.</p>
60 <em>Скриншот: Skillbox Media</em><p>Допустим, мы захотели изменить внешний вид заголовков и добавить иконки. Для этого необходимо создать элемент заголовка с уникальным Presenter, а также создать PresenterSelector - он выполняет роль Presenter для каждого элемента списка.</p>
60 <em>Скриншот: Skillbox Media</em><p>Допустим, мы захотели изменить внешний вид заголовков и добавить иконки. Для этого необходимо создать элемент заголовка с уникальным Presenter, а также создать PresenterSelector - он выполняет роль Presenter для каждого элемента списка.</p>
61 <p>Начнём с элемента заголовка:</p>
61 <p>Начнём с элемента заголовка:</p>
62 class IconHeaderItem( name: String, id: Long = -1, val iconResId: Int = NO_ICON ) : HeaderItem(id, name) { companion object { val NO_ICON = -1 } }<p>Мы расширили стандартный HeaderItem, добавив возможность устанавливать id‑иконки из ресурсов. Сделаем вёрстку для нового заголовка:</p>
62 class IconHeaderItem( name: String, id: Long = -1, val iconResId: Int = NO_ICON ) : HeaderItem(id, name) { companion object { val NO_ICON = -1 } }<p>Мы расширили стандартный HeaderItem, добавив возможность устанавливать id‑иконки из ресурсов. Сделаем вёрстку для нового заголовка:</p>
63 &lt;TextView xmlns:android="http://schemas.android.com/apk/res/android" android:focusable="true" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" android:drawablePadding="16dp" android:textSize="18sp"/&gt;<p>Создадим свой презентер, который умеет отрисовывать IconHeaderItem:</p>
63 &lt;TextView xmlns:android="http://schemas.android.com/apk/res/android" android:focusable="true" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" android:drawablePadding="16dp" android:textSize="18sp"/&gt;<p>Создадим свой презентер, который умеет отрисовывать IconHeaderItem:</p>
64 class IconRowHeaderPresenter : RowHeaderPresenter() { override fun onCreateViewHolder(viewGroup: ViewGroup): ViewHolder { val view = LayoutInflater .from(viewGroup.context) .inflate(R.layout.item_icon_header, viewGroup, false) val viewHolder = ViewHolder(view) setSelectLevel(viewHolder, 0f) return viewHolder } override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, data: Any) { val row = data as? Row val iconHeaderItem = row?.headerItem as? IconHeaderItem iconHeaderItem?.let { val tv = viewHolder .view .textView if (iconHeaderItem.iconResId != IconHeaderItem.NO_ICON) { tv.setCompoundDrawablesWithIntrinsicBounds(iconHeaderItem.iconResId, 0, 0, 0) } tv.text = iconHeaderItem.name } } override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) { val tv = viewHolder .view .textView tv.text = "" tv.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) } }<p>Установим новый PresenterSelector для заголовков. Для этого у BrowseFragment есть метод<em>setHeaderPresenterSelector</em>:</p>
64 class IconRowHeaderPresenter : RowHeaderPresenter() { override fun onCreateViewHolder(viewGroup: ViewGroup): ViewHolder { val view = LayoutInflater .from(viewGroup.context) .inflate(R.layout.item_icon_header, viewGroup, false) val viewHolder = ViewHolder(view) setSelectLevel(viewHolder, 0f) return viewHolder } override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, data: Any) { val row = data as? Row val iconHeaderItem = row?.headerItem as? IconHeaderItem iconHeaderItem?.let { val tv = viewHolder .view .textView if (iconHeaderItem.iconResId != IconHeaderItem.NO_ICON) { tv.setCompoundDrawablesWithIntrinsicBounds(iconHeaderItem.iconResId, 0, 0, 0) } tv.text = iconHeaderItem.name } } override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) { val tv = viewHolder .view .textView tv.text = "" tv.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) } }<p>Установим новый PresenterSelector для заголовков. Для этого у BrowseFragment есть метод<em>setHeaderPresenterSelector</em>:</p>
65 setHeaderPresenterSelector(object : PresenterSelector() { val presenter = IconRowHeaderPresenter() val defaultPresenterSelector = headersSupportFragment.presenterSelector override fun getPresenter(data: Any): Presenter { return if ((data as? Row)?.headerItem is IconHeaderItem) presenter else defaultPresenterSelector.getPresenter(data) } })<p>Теперь смотрим на заголовок Row. Если он IconHeaderItem, то возвращаем Presenter, который умеет с ним работать - в данном случае IconRowHeaderPresenter. Заменим заголовки на новые:</p>
65 setHeaderPresenterSelector(object : PresenterSelector() { val presenter = IconRowHeaderPresenter() val defaultPresenterSelector = headersSupportFragment.presenterSelector override fun getPresenter(data: Any): Presenter { return if ((data as? Row)?.headerItem is IconHeaderItem) presenter else defaultPresenterSelector.getPresenter(data) } })<p>Теперь смотрим на заголовок Row. Если он IconHeaderItem, то возвращаем Presenter, который умеет с ним работать - в данном случае IconRowHeaderPresenter. Заменим заголовки на новые:</p>
66 val firstHeader = IconHeaderItem("Заголовок 1", iconResId = R.drawable.ic_header_item) val secondHeader = IconHeaderItem("Заголовок 2", iconResId = R.drawable.ic_header_item) val thirdHeader = IconHeaderItem("Заголовок 3", iconResId = R.drawable.ic_header_item)<p>Получаем результат:</p>
66 val firstHeader = IconHeaderItem("Заголовок 1", iconResId = R.drawable.ic_header_item) val secondHeader = IconHeaderItem("Заголовок 2", iconResId = R.drawable.ic_header_item) val thirdHeader = IconHeaderItem("Заголовок 3", iconResId = R.drawable.ic_header_item)<p>Получаем результат:</p>
67 Скриншот: Skillbox Media<p>Таким же образом можно кастомизировать и подзаголовки в SectionRow или разделители DividerRow.</p>
67 Скриншот: Skillbox Media<p>Таким же образом можно кастомизировать и подзаголовки в SectionRow или разделители DividerRow.</p>
68 <p>Допустим, нам нужно отобразить в правой части экрана большое количество элементов. По стандарту все они будут отображены в одну строку:</p>
68 <p>Допустим, нам нужно отобразить в правой части экрана большое количество элементов. По стандарту все они будут отображены в одну строку:</p>
69 <em>Скриншот: Skillbox Media</em><p>Но нам это не подходит - мы хотим отобразить элементы в виде сетки. Сделать это можно с помощью кастомного Row.</p>
69 <em>Скриншот: Skillbox Media</em><p>Но нам это не подходит - мы хотим отобразить элементы в виде сетки. Сделать это можно с помощью кастомного Row.</p>
70 <p>Создадим свой GridListRow, который будет отображать данные в виде сетки, а не списка:</p>
70 <p>Создадим свой GridListRow, который будет отображать данные в виде сетки, а не списка:</p>
71 class GridListRow( header: HeaderItem, adapter: ObjectAdapter, val numRows: Int ) : ListRow( header, adapter )<p>Затем расширим возможности ListRowPresenter и научим его работать с GridListRow:</p>
71 class GridListRow( header: HeaderItem, adapter: ObjectAdapter, val numRows: Int ) : ListRow( header, adapter )<p>Затем расширим возможности ListRowPresenter и научим его работать с GridListRow:</p>
72 class GridListRowPresenter : ListRowPresenter() { override fun onBindRowViewHolder(holder: RowPresenter.ViewHolder, item: Any) { (holder as ViewHolder).gridView.setNumRows((item as? GridListRow)?.numRows ?: 1) super.onBindRowViewHolder(holder, item) } override fun isUsingDefaultShadow(): Boolean = false }<p>Теперь установим GridListRow в качестве Presenter адаптера BrowseFragment:</p>
72 class GridListRowPresenter : ListRowPresenter() { override fun onBindRowViewHolder(holder: RowPresenter.ViewHolder, item: Any) { (holder as ViewHolder).gridView.setNumRows((item as? GridListRow)?.numRows ?: 1) super.onBindRowViewHolder(holder, item) } override fun isUsingDefaultShadow(): Boolean = false }<p>Теперь установим GridListRow в качестве Presenter адаптера BrowseFragment:</p>
73 val browseAdapter = ArrayObjectAdapter(GridListRowPresenter())<p>Теперь используем GridListAdapter в RowsFragment:</p>
73 val browseAdapter = ArrayObjectAdapter(GridListRowPresenter())<p>Теперь используем GridListAdapter в RowsFragment:</p>
74 browseAdapter.add(GridListRow(firstHeader, rowsAdapter, 2)) browseAdapter.add(DividerRow()) browseAdapter.add(GridListRow(secondHeader, rowsAdapter, 3)) browseAdapter.add(DividerRow()) browseAdapter.add(GridListRow(thirdHeader, rowsAdapter, 4)) browseAdapter.add(SectionRow("Подзаголовок 1"))<p>Получился следующий список:</p>
74 browseAdapter.add(GridListRow(firstHeader, rowsAdapter, 2)) browseAdapter.add(DividerRow()) browseAdapter.add(GridListRow(secondHeader, rowsAdapter, 3)) browseAdapter.add(DividerRow()) browseAdapter.add(GridListRow(thirdHeader, rowsAdapter, 4)) browseAdapter.add(SectionRow("Подзаголовок 1"))<p>Получился следующий список:</p>
75 <em>Скриншот: Skillbox Media</em><p>BrowseFragment позволяет заменить стандартную реализацию RowsFragment на кастомную при помощи FragmentFactory. Эта фабрика отвечает за создание фрагмента для текущего элемента.</p>
75 <em>Скриншот: Skillbox Media</em><p>BrowseFragment позволяет заменить стандартную реализацию RowsFragment на кастомную при помощи FragmentFactory. Эта фабрика отвечает за создание фрагмента для текущего элемента.</p>
76 <p>Сделаем так, чтобы RowsFragment всегда показывал внутренние заголовки. Для начала создадим свою реализацию RowsFragment для работы со списками:</p>
76 <p>Сделаем так, чтобы RowsFragment всегда показывал внутренние заголовки. Для начала создадим свою реализацию RowsFragment для работы со списками:</p>
77 class ListRowsFragment : RowsSupportFragment() { override fun setExpand(expand: Boolean) { super.setExpand(true) } companion object { fun newInstance() = ListRowsFragment() } }<p>Здесь мы переопределили метод<em>setExpand</em>, чтобы всегда передавать true в качестве аргумента родительского метода. Теперь заголовки всегда будут показываться.</p>
77 class ListRowsFragment : RowsSupportFragment() { override fun setExpand(expand: Boolean) { super.setExpand(true) } companion object { fun newInstance() = ListRowsFragment() } }<p>Здесь мы переопределили метод<em>setExpand</em>, чтобы всегда передавать true в качестве аргумента родительского метода. Теперь заголовки всегда будут показываться.</p>
78 <p>Далее реализуем фабрику FragmentFactory, которая будет возвращать ListRowFragment:</p>
78 <p>Далее реализуем фабрику FragmentFactory, которая будет возвращать ListRowFragment:</p>
79 private val listRowFragmentFactory = object : FragmentFactory&lt;Fragment&gt;() { override fun createFragment(row: Any): Fragment? = ListRowsFragment.newInstance() }<p>И установим её в качестве фабрики, возвращающей фрагмент для GridListRow:</p>
79 private val listRowFragmentFactory = object : FragmentFactory&lt;Fragment&gt;() { override fun createFragment(row: Any): Fragment? = ListRowsFragment.newInstance() }<p>И установим её в качестве фабрики, возвращающей фрагмент для GridListRow:</p>
80 mainFragmentRegistry.registerFragment(GridListRow::class.java, listRowFragmentFactory)<p>Теперь заголовки RowsFragment будут отображаться всегда.</p>
80 mainFragmentRegistry.registerFragment(GridListRow::class.java, listRowFragmentFactory)<p>Теперь заголовки RowsFragment будут отображаться всегда.</p>
81 <em>Скриншот: Skillbox Media</em><p>Выше были рассмотрены варианты с ListRow, где каждому элементу заголовка из левой части ставился в соответствие список элементов в правой части экрана. Теперь рассмотрим ситуацию, когда каждому заголовку из левой части ставится в соответствие не список, а отдельная страница в правой части экрана.</p>
81 <em>Скриншот: Skillbox Media</em><p>Выше были рассмотрены варианты с ListRow, где каждому элементу заголовка из левой части ставился в соответствие список элементов в правой части экрана. Теперь рассмотрим ситуацию, когда каждому заголовку из левой части ставится в соответствие не список, а отдельная страница в правой части экрана.</p>
82 <p>Реализовать такое поведение можно через PageRow совместно с FragmentFactory. Для начала создадим фрагмент, который будет представлять собой отдельную страницу:</p>
82 <p>Реализовать такое поведение можно через PageRow совместно с FragmentFactory. Для начала создадим фрагмент, который будет представлять собой отдельную страницу:</p>
83 class PageRowsFragment : RowsSupportFragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) initView() } private fun initView() { val pageId = arguments?.getLong(PAGE_ID) val headerTitle = pageId?.let { "Страница $pageId" } ?: "" val outerAdapter = ArrayObjectAdapter(ListRowPresenter()) val rowsAdapter = ArrayObjectAdapter(TextPresenter()) val commonHeader = HeaderItem(headerTitle) rowsAdapter.add("Элемент 1") rowsAdapter.add("Элемент 2") rowsAdapter.add("Элемент 3") outerAdapter.add(ListRow(commonHeader, rowsAdapter)) outerAdapter.add(ListRow(commonHeader, rowsAdapter)) outerAdapter.add(ListRow(commonHeader, rowsAdapter)) outerAdapter.add(ListRow(commonHeader, rowsAdapter)) adapter = outerAdapter } companion object { private const val PAGE_ID = "page id" fun newInstance(pageId: Long) = PageRowsFragment() .apply { val bundle = Bundle() bundle.putLong(PAGE_ID, pageId) arguments = bundle } } }<p>У RowsFragment, так же как и у BrowseFragment, есть общий внешний и внутренний адаптеры для каждого списка элементов. При создании PageRowsFragment мы передаём ему id текущего элемента заголовка BrowseFragment и устанавливаем в качестве заголовка списка.</p>
83 class PageRowsFragment : RowsSupportFragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) initView() } private fun initView() { val pageId = arguments?.getLong(PAGE_ID) val headerTitle = pageId?.let { "Страница $pageId" } ?: "" val outerAdapter = ArrayObjectAdapter(ListRowPresenter()) val rowsAdapter = ArrayObjectAdapter(TextPresenter()) val commonHeader = HeaderItem(headerTitle) rowsAdapter.add("Элемент 1") rowsAdapter.add("Элемент 2") rowsAdapter.add("Элемент 3") outerAdapter.add(ListRow(commonHeader, rowsAdapter)) outerAdapter.add(ListRow(commonHeader, rowsAdapter)) outerAdapter.add(ListRow(commonHeader, rowsAdapter)) outerAdapter.add(ListRow(commonHeader, rowsAdapter)) adapter = outerAdapter } companion object { private const val PAGE_ID = "page id" fun newInstance(pageId: Long) = PageRowsFragment() .apply { val bundle = Bundle() bundle.putLong(PAGE_ID, pageId) arguments = bundle } } }<p>У RowsFragment, так же как и у BrowseFragment, есть общий внешний и внутренний адаптеры для каждого списка элементов. При создании PageRowsFragment мы передаём ему id текущего элемента заголовка BrowseFragment и устанавливаем в качестве заголовка списка.</p>
84 <p>Создадим FragmentFactory, которая будет возвращать PageRowFragment:</p>
84 <p>Создадим FragmentFactory, которая будет возвращать PageRowFragment:</p>
85 private val pageRowFragmentFactory = object : FragmentFactory&lt;Fragment&gt;() { override fun createFragment(row: Any): Fragment? = (row as? PageRow) ?.headerItem ?.id ?.let { PageRowsFragment.newInstance(it) } }<p>Установим его в качестве фабрики, возвращающей фрагменты для PageRow:</p>
85 private val pageRowFragmentFactory = object : FragmentFactory&lt;Fragment&gt;() { override fun createFragment(row: Any): Fragment? = (row as? PageRow) ?.headerItem ?.id ?.let { PageRowsFragment.newInstance(it) } }<p>Установим его в качестве фабрики, возвращающей фрагменты для PageRow:</p>
86 mainFragmentRegistry.registerFragment(PageRow::class.java, pageRowFragmentFactory)<p>Теперь каждый элемент заголовка BrowseFragment имеет свой собственный RowsFragment, переключение между которыми происходит при выборе нового заголовка:</p>
86 mainFragmentRegistry.registerFragment(PageRow::class.java, pageRowFragmentFactory)<p>Теперь каждый элемент заголовка BrowseFragment имеет свой собственный RowsFragment, переключение между которыми происходит при выборе нового заголовка:</p>
87 Скриншот: Skillbox Media<p>Иногда в один ListRow нужно добавить объекты разного типа. Для этого под элемент каждого типа необходимо создать свой Presenter, который будет возвращать PresenterSelector. В Leanback уже есть готовая реализация PresenterSelector, которая возвращает Presenter для каждого элемента списка по типу его класса - ClassPresenterSelector.</p>
87 Скриншот: Skillbox Media<p>Иногда в один ListRow нужно добавить объекты разного типа. Для этого под элемент каждого типа необходимо создать свой Presenter, который будет возвращать PresenterSelector. В Leanback уже есть готовая реализация PresenterSelector, которая возвращает Presenter для каждого элемента списка по типу его класса - ClassPresenterSelector.</p>
88 <p>Создадим два класса TextItem и ImageItem. В первый будем передавать текст для элемента, во второй - id изображения из ресурсов:</p>
88 <p>Создадим два класса TextItem и ImageItem. В первый будем передавать текст для элемента, во второй - id изображения из ресурсов:</p>
89 data class TextItem( val text: String ) data class ImageItem( @DrawableRes val imageId: Int )<p>Перепишем TextPresenter таким образом, чтобы он умел работать с элементами TextItem. В rowsAdapter будем добавлять не String, а TextItem:</p>
89 data class TextItem( val text: String ) data class ImageItem( @DrawableRes val imageId: Int )<p>Перепишем TextPresenter таким образом, чтобы он умел работать с элементами TextItem. В rowsAdapter будем добавлять не String, а TextItem:</p>
90 rowsAdapter.add(TextItem("Элемент 1"))<p>Добавим ImagePresenter, работающий с ImageItem:</p>
90 rowsAdapter.add(TextItem("Элемент 1"))<p>Добавим ImagePresenter, работающий с ImageItem:</p>
91 class ImagePresenter : Presenter() { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { val view = ImageView(parent.context) view.layoutParams = ViewGroup.LayoutParams(600, 400) view.isFocusable = true view.isFocusableInTouchMode = true view.scaleType = ImageView.ScaleType.CENTER_CROP view.setBackgroundColor( ContextCompat.getColor( parent.context, android.R.color.background_light ) ) return ViewHolder(view) } override fun onBindViewHolder(viewHolder: Presenter.ViewHolder?, item: Any?) = (viewHolder as ViewHolder).bind(item as ImageItem) override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder?) = (viewHolder as ViewHolder).unbind() class ViewHolder(private val imageView: ImageView) : Presenter.ViewHolder(imageView) { private var requestManager: RequestManager? = null fun bind(imageItem: ImageItem) { requestManager = Glide.with(imageView) requestManager?.load(imageItem.imageId)?.into(imageView) } fun unbind() { requestManager?.clear(imageView) imageView.setImageDrawable(null) } } }<p>ImagePresenter загружает изображение из ресурсов, показывает его и помещает в ImageView. Создадим ClassPresenterSelector и укажем, какие данные отображает каждый Presenter:</p>
91 class ImagePresenter : Presenter() { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { val view = ImageView(parent.context) view.layoutParams = ViewGroup.LayoutParams(600, 400) view.isFocusable = true view.isFocusableInTouchMode = true view.scaleType = ImageView.ScaleType.CENTER_CROP view.setBackgroundColor( ContextCompat.getColor( parent.context, android.R.color.background_light ) ) return ViewHolder(view) } override fun onBindViewHolder(viewHolder: Presenter.ViewHolder?, item: Any?) = (viewHolder as ViewHolder).bind(item as ImageItem) override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder?) = (viewHolder as ViewHolder).unbind() class ViewHolder(private val imageView: ImageView) : Presenter.ViewHolder(imageView) { private var requestManager: RequestManager? = null fun bind(imageItem: ImageItem) { requestManager = Glide.with(imageView) requestManager?.load(imageItem.imageId)?.into(imageView) } fun unbind() { requestManager?.clear(imageView) imageView.setImageDrawable(null) } } }<p>ImagePresenter загружает изображение из ресурсов, показывает его и помещает в ImageView. Создадим ClassPresenterSelector и укажем, какие данные отображает каждый Presenter:</p>
92 val rowsPresenterSelector = ClassPresenterSelector() .addClassPresenter(ImageItem::class.java, ImagePresenter()) .addClassPresenter(TextItem::class.java, TextPresenter())<p>Затем передадим этот презентер в адаптер RowsFragment:</p>
92 val rowsPresenterSelector = ClassPresenterSelector() .addClassPresenter(ImageItem::class.java, ImagePresenter()) .addClassPresenter(TextItem::class.java, TextPresenter())<p>Затем передадим этот презентер в адаптер RowsFragment:</p>
93 val rowsAdapter = ArrayObjectAdapter(rowsPresenterSelector)<p>и добавим несколько элементов типа ImageItem:</p>
93 val rowsAdapter = ArrayObjectAdapter(rowsPresenterSelector)<p>и добавим несколько элементов типа ImageItem:</p>
94 rowsAdapter.add(ImageItem(R.drawable.image1)) rowsAdapter.add(ImageItem(R.drawable.image2)) rowsAdapter.add(ImageItem(R.drawable.image3))<p>Теперь в одном списке могут присутствовать элементы разных типов:</p>
94 rowsAdapter.add(ImageItem(R.drawable.image1)) rowsAdapter.add(ImageItem(R.drawable.image2)) rowsAdapter.add(ImageItem(R.drawable.image3))<p>Теперь в одном списке могут присутствовать элементы разных типов:</p>
95 Скриншот: Skillbox Media<p>Мы рассмотрели возможности BrowseFragment, который является частью библиотеки Leanback. Но Leanback не ограничивается этим шаблоном. Она настолько богатая, что её элементов хватит для создания полноценного приложения Android TV с учётом всех <a>гайдлайнов</a>.</p>
95 Скриншот: Skillbox Media<p>Мы рассмотрели возможности BrowseFragment, который является частью библиотеки Leanback. Но Leanback не ограничивается этим шаблоном. Она настолько богатая, что её элементов хватит для создания полноценного приложения Android TV с учётом всех <a>гайдлайнов</a>.</p>
96 <p>А <a>здесь</a>можно посмотреть исходники проекта.</p>
96 <p>А <a>здесь</a>можно посмотреть исходники проекта.</p>
97 <a><b>Бесплатный курс по Python ➞</b>Мини-курс для новичков и для опытных кодеров. 4 крутых проекта в портфолио, живое общение со спикером. Кликните и узнайте, чему можно научиться на курсе. Смотреть программу</a>
97 <a><b>Бесплатный курс по Python ➞</b>Мини-курс для новичков и для опытных кодеров. 4 крутых проекта в портфолио, живое общение со спикером. Кликните и узнайте, чему можно научиться на курсе. Смотреть программу</a>