Все

Работа с CoordinatorLayout: шапочный разбор

Статьи android
Приветствую тебя, уважаемый читатель! Меня зовут Пятаков Никита, я Android-инженер в команде MT&S Авито. Мы отвечаем за распродажи для покупателей: делаем яркие баннеры, функциональные виджеты и другие UI-фичи.
В этой статье я поделюсь опытом по реализации двух версий шапки на главном экране приложения Avito:
Слева первая версия шапки, справа – вторая
Слева первая версия шапки, справа – вторая/
Реализации фичей очень похожи, поэтому в своем рассказе я остановлюсь на второй версии. Эта статья будет особенно полезна разработчикам, которые хотят познакомиться с CoordinatorLayout и научиться делать похожие задачки с нетривиальным взаимодействием view между собой. Да, тема не нова, но, тем не менее, она не теряет своей актуальности! Итак, обо всем по порядку.

UX/UI

Необходимо анимировать скролл – шапка умеет скрываться и показываться при скролле. При этом у нее меняется прозрачность и отдельные элементы верстки движутся с разными скоростями. Также не забываем про p2r:
Если пользователь закончил касание в промежуточном состоянии, шапка должна доскролливаться с анимацией – закрыться или открыться. Также, при нажатии на поиск, нужно шапку скрыть и, хотя мы находимся на том же экране и показываем ту же строку поиска, заблокировать возможность показа. Если из поиска возвращаемся на главную – через delay в 1 секунду шапка открывается сама:

Архитектурный контекст

Буквально пара слов о presentation-слое, дабы упростить понимание и повысить читабельность кода. Мы используем MVI:
  • во фрагменте инициализируется стейт, при рендеринге вызываем у view показ шапки;
  • внутри view-экрана живет view шапки, происходит показ;
  • общение view с фрагментом осуществляется с помощью экшенов.
Упрощенная схема работы с UI шапки:
Упрощенная схема работы с UI шапки
Теперь перейдем к реализации.

Coordinator Layout

CoordinatorLayout — это специальный ViewGroup, основа для создания различных интерактивных элементов и анимаций за счет обеспечения гибкого управления поведением его дочерних компонентов.
Координатор использует специальные классы behavior, в которых описывается поведение View, он может наблюдать за изменениями состояния одного View и, соответственно, изменять состояние другого View. В общем, этот инструмент создан специально для реализации таких вот шапок.
В рамках нашей задачи необходимо было выбрать между двумя behaviour: BottomSheet и AppBarLayout. По итогу выбор пал на второй вариант, по той простой причине, что этот behaviour не так значительно меняет иерархию view. По сути – создается контейнер, который встраивается в верхней части экрана.
BottomSheet же подразумевает создание нового контейнера, в который помещается все содержимого экрана за исключением шапки. Конкретно для нашей задачи это плохо, потому что на главном экране приложения много корнер-кейсов (онбординг, всплывающие шторки, специальные режимы для особых пользователей и так далее), которые легко ломаются и трудно поддерживаются при таком подходе.

Вёрстка

В xml фрагмента в качестве родительского контейнера выставляем CoordinatorLayout и встраиваем шапки с установкой выбранного нами behaviour:
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<include layout="@layout/main_app_bar" /> <!-- шапки -->

<!-- остальной контент, у которого выставляем
app:layout_behavior=
"com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
-->

</androidx.coordinatorlayout.widget.CoordinatorLayout>
Здесь необходимо запомнить, что behaviour нужно указывать только у прямых наследников CoordinatorLayout. Для опосредованных эта установка работать просто не будет.
Теперь взглянем на main_app_bar.xml:
<com.google.android.material.appbar.AppBarLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|snap|exitUntilCollapsed">

<include layout="@layout/short_main_promo_header"/>

<include layout="@layout/main_promo_header" />

</com.google.android.material.appbar.CollapsingToolbarLayout>

</com.google.android.material.appbar.AppBarLayout>
AppBarLayout – это контейнер-наследник LinearLayout, который используется в связке с CoordinatorLayout и позволяет управлять взаимодействием view, это делается с помощью кастомного ShortPromoHeaderAppbarBehaviour, о котором будет сказано ниже. Также я использовал функцию AppBarLayout.setExpanded – показ/скрытие шапки из кода с анимацией.
CollapsingToolbarLayout содержит уже реализованные UI-фичи, которые можно настраивать с помощью layout_scrollFlags:
  • scroll – установка прокрутки шапки при прокрутке контента;
  • snap – доскролл до состояния открытия/закрытия шапки, если пользователь отпустил палец в промежуточном состоянии;
  • exitUntilCollapsed – за счет этого флага контент экрана будет находиться поверх шапки во время скролла, а не наоборот.
Сами эти чудо-шапки лежат внутри, первая версия именуется main_promo_header, вторая – short_main_promo_header. Как и договаривались, остановимся подробнее на последней.

Custom AppBarLayout.Behaviour

Если пристально посмотреть на шапку при скролле, можно заметить три различных поведения:
  • задний фон (градиент + картинка) стоит на месте;
  • нижний контент шапки (текст со стрелкой + картинка) появляются и скрываются в 2 раза быстрее скролла основного контента;
  • нижние скругления (это ImageView) скроллятся с той же скоростью, что и основной контент.
Исходя из этого, я поместил в отдельные контейнеры фон и нижний контент, чтобы задавать им скорость скролла в бихейворе ShortPromoHeaderAppbarBehaviour. Теперь поподробнее о нем.
При создании бихейвора (я делал это во view) прокидываем значение максимально допустимой высоты (maximumVerticalOffset), которое будет использоваться при вычислении прогресса скролла шапки. Переопределяем метод onLayoutChild, получаем ссылки на все интересующие нас view в шапке:
override fun onLayoutChild(
parent: CoordinatorLayout,
abl: AppBarLayout,
layoutDirection: Int
): Boolean {
val superLayout = super.onLayoutChild(parent, abl, layoutDirection)
if (collapsingLayout == null) initialize(abl)
return superLayout
}

private fun initialize(abl: AppBarLayout) {
collapsingLayout = abl.getChildAt(0) as CollapsingToolbarLayout
promoHeaderBackground = abl.findViewById(R.id.short_main_promo_header_background)
promoHeaderBottom = abl.findViewById(R.id.short_main_promo_header_bottom)
appBar = abl
}
Реализация скрытия шапки при открытии поиска – добавляем флаг, который контролирует скролл в onNestedScroll, создаем публичный метод для изменения значения флага и вызываем changeScrollBlocking во view с соответствующим значением параметра isBlock при открытии/скрытии поиска:
private var isBlockScroll = false

fun changeScrollBlocking(isBlock: Boolean) {
isBlockScroll = isBlock
}

override fun onNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: AppBarLayout,
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
super.onNestedScroll(
coordinatorLayout,
child,
target,
dxConsumed,
dyConsumed,
dxUnconsumed,
if (isBlockScroll) {
0
} else {
dyUnconsumed
},
type,
consumed
)
}
Вообще для работы с анимацией во время скролла существуют коллбеки onNestedPreScroll и onNestedScroll, но у меня возникли проблемы, связанные с p2r – прокидываются значения dy, которые портят отслеживание стейта шапки. Поэтому я сделал все через setTopAndBottomOffset – эта функция вызывается при скролле и передает в качестве параметра смещение шапки:
override fun setTopAndBottomOffset(offset: Int): Boolean {
val scrollProgress = MathUtils.clamp(-offset / maximumVerticalOffset, 0f, 1f)
appBar?.alpha = 1 - scrollProgress
promoHeaderBackground?.translationY = scrollProgress
promoHeaderBottom?.translationY = scrollProgress * 2
return super.setTopAndBottomOffset(offset)
}
При скрытии шапки offset приходит отрицательный, поэтому выставляем все значения со знаком минус. Здесь мы:
  • меняем прозрачность шапки так, что альфа равна 1 при полном отображении и 0 при полном скрытии;
  • смещаем фон настолько же, насколько произошел скролл;
  • смещаем нижнюю часть шапки на значение, в 2 раза большее скролла.
Снова важный момент – обязательно нормализуем значение scrollProgress! Я столкнулся с ситуацией, когда пользователь скроллит шапку, где-то посередине скролла отпускает палец и, пока шапка сама доскролливается, нажимает на поиск – во view при этом вызывается функция setExpanded с animate = false, а в setTopAndBottomOffset передается положительный offset. Итог – краш, потому что alpha не принимает значения больше 1… В общем, «защита от дурака» еще никому не мешала.

PromoHeaderView

Во view мы также можем подписаться на прогресс скролла шапки:
init {
collapsingToolbarView.minimumHeight = minHeight
collapsingToolbarView.fitsSystemWindows = true
view.addOnOffsetChangedListener { _, verticalOffset ->
val percentage = MathUtils.clamp(abs(verticalOffset / maximumVerticalOffset), 0f, 1f)
actionsFlow.tryEmit(BxContentAction.AnimateToolbar(percentage))
when (percentage) {
0f -> {
actionsFlow.tryEmit(BxContentAction.ChangeShortMainPromoHeaderState(false))
actionsFlow.tryEmit(BxContentAction.ChangeToolbarBackground(false))
}

1f -> {
actionsFlow.tryEmit(BxContentAction.ChangeShortMainPromoHeaderState(true))
actionsFlow.tryEmit(BxContentAction.ChangeToolbarBackground(true))
}
}
}
}
В этом случае мы анимируем строку поиска и обновляем стейт шапки во фрагменте. Не забываем выставить minHeight для collapsingToolbar, чтобы он не сворачивался полностью и контент главной не уходил за строку поиска. И убираем статус-бар, так как по дизайну требовалось, чтобы на его место "заехал" Image. Вуаля, все готово!
Кстати, вы наверняка подумали, что в первой версии шапки используется BottomSheet.Behaviour, однако я писал ее также на основе именно AppBarLayout. Этого удалось достичь с помощью специального свойства у AppBarLayout, который позволяет контенту главной «наехать» на него:
(params.behavior as? AppBarLayout.ScrollingViewBehavior)?.overlayTop = 16.dp
На этом мой рассказ окончен, надеюсь, что это статья была для вас полезна!
О том, как мы ускоряем Android-приложения с помощью Baseline Profiles, читайте в статье моего коллеги Даниля Гатиатуллина. Там рассказано, как во время эксперимента удалось уменьшить время запуска приложения на 15% и автоматизировать добавление профилей в каждый релиз.