Привет! Меня зовут Андрей Шоколов, я Android-разработчик KODE.

К нам обратилась компания Forward Leasing с запросом разработать мобильное приложение по готовому дизайну. Прототип содержал дугу, которая по задумке должна была сжиматься в одну линию при скролле. За основу решили взять CoordinatorLayout: у нас уже был положительный опыт работы с ним на другом проекте. Ещё в нашей команде часто любили соревноваться, какой же Layout лучше — CoordinatorLayout или MotionLayout, и именно сейчас настало время узнать.

Сейчас я понимаю, что проблема была создана на ровном месте, — но это я выяснил только в процессе работы. В статье расскажу, с какими 7 сложностями CoordinatorLayout я столкнулся и как сделать за полчаса то, с чем я провозился сутки.

Задача проекта

Мне нужно было создать AppBarLayout с дугой, который скроллится в одну линию. Функциональность добавлялась в существующую Activity приложения Forward. Корневым элементом для главной Activity был выбран CoordinatorLayout — это ViewGroup, целью которой является координация внутренних view-элементов.

Главный экран мобильного приложения Forward Leasing
Главный экран мобильного приложения Forward Leasing

Прежде чем я расскажу, что такое CoordinatorLayout и с какими сложностями в реализации я столкнулся, покажу, как в итоге должно было получиться:

Финальный результат

Что такое CoordinatorLayout с Default behavior

CoordinatorLayout — это просто обычный FrameLayout. Нет, простите: как написано в документации, это super-powered FrameLayout!

Главная фишка CoordinatorLayout — свои Default behaviors
Главная фишка CoordinatorLayout — свои Default behaviors

С помощью Behaviors можно управлять дочерней View. Думаю, все видели эти красивые анимации, когда снизу выплывает Snackbar, а вместе с ним вверх поднимается FAB (Floating Action Button). Всё работает отлично. Но именно Behaviors и подставят меня позднее, потому что когда тебе нужно делать не всё по дефолту, а добавлять что-то своё, то возникают некоторые сложности. Для реализации такой дуги был создан класс RoundedAppBarLayout, наследующий AppBarLayout.

Что дано в RoundedAppBarLayout

  • Toolbar — самая обычная вью тулбара из Android.

  • ContentView, где может быть всё, что угодно.

  • Arc — наша дуга.

С какими сложностями я столкнулся и как их решал

№1. Скроллинг

У нас есть RoundedAppBarLayout, в котором находятся Toolbar, ContentView и Arc. Для каждого элемента вью внутри него можно задать scrollFlags, которые описывают поведение вью при скролле:  scroll, snap, expandAlways, expandAlwaysCollapsed, exitUntilCollapsed. Как они работают? AppBarLayout проходится по всем дочерним вью — child’ам, которые у него есть, и смотрит: если выставлен scroll, тогда добавить то, что эта вьюшка скроллится, если noScroll — return. 

Если на Toolbar написано scroll, то он прокрутится, а всё, что находится ниже него, — нет. Соответственно, если поставить scroll на Toolbar и ContentView, то прокручиваться будут только они. Нам же нужно ровно наоборот: чтобы скроллилась дуга, а не всё, что выше неё.

Решение

CollapsingToolbar. Он позволяет оставить Toolbar наверху и даёт возможность прокручивать ContentView, Arc и содержимое ниже. Это решение работает достаточно хорошо, за исключением того, что Toolbar должен быть всегда последним элементом CollapsingToolbar.

Код
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/transparent"
    tools:parentTag="com.google.android.material.appbar.AppBarLayout">
 
    <com.google.android.material.appbar.CollapsingToolbarLayout
        android:id="@+id/collapsingToolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/transparent"
        app:layout_scrollFlags="scroll|snap|exitUntilCollapsed">
 
    </com.google.android.material.appbar.CollapsingToolbarLayout>
</merge>

№2. Прозрачные зоны в Arc и отрисовка дуги

Для того чтобы нарисовать дугу в AppBarLayout, в блоке Arc должны находиться зоны, которые будут прозрачными. Поэтому выставляем AppBarLayout transparent, как и ContentView: мы не знаем, какого цвета он должен быть.

Но если мы оставим CollapsingToolbar, который вложен в AppBar, без цвета, как и наш ContentView, то на выходе мы получим прозрачный прямоугольник.

Решение

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

Как это выглядит

Остаётся вопрос с дугой. Мы не можем поместить её в в CollapsingToolbar, потому что он имеет свой цвет, а Arc должна быть transparent. Получается, мы должны поместить дугу в AppBarLayout, но он, как мы помним, не даёт скроллить всё, что внизу. 

Решение

Рисовать на канвасе и во время скролла просчитывать, как рисовать дугу. 

Слева показано, как выглядит структура Layout'а
Слева показано, как выглядит структура Layout'а

Элемент нужно добавить в CollapsingToolbar. Можно было бы сразу добавить сюда Toolbar, но так как нужно, чтобы он был последним элементом в CollapsingToolbarLayout, его приходится записывать отдельно. То есть я делаю Inflate Layout’а и добавляю в конец CollapsingToolbarLayout.

Код
private fun addContentView(){
        if (childCount > 1) {
            val contentChild = this.getChildAt(1)
            removeView(contentChild)
 
            val layoutParams = CollapsingToolbarLayout.LayoutParams(contentChild.layoutParams)
            with(contentChild) {
                layoutParams.setMargins(
                    marginStart,
                    marginTop + toolbarHeight,
                    marginEnd,
                    marginBottom
                )
            }
            contentChild.setBackgroundColor(context.getColor(R.color.accent2))
            contentChild.layoutParams = layoutParams
 
            collapsingToolbar.addView(contentChild)
        }
    }
 
    override fun onFinishInflate() {
        super.onFinishInflate()
        addContentView()
 
        val toolbar = LayoutInflater.from(context)
            .inflate(R.layout.toolbar_default, collapsingToolbar, false)
        removeView(toolbar)
        collapsingToolbar.addView(toolbar)
    }

В итоге получился такой простенький класс, где есть атрибуты, title, где можно задать иконку для Toolbar, добавить ContentView, который находится в самом нашем RoundedAppBarLayout, и нарисовать дугу.

Доступные методы
Доступные методы

№3. Пустое пространство после ContentView

С чем столкнулись дальше. После Toolbar и ContentView у нас нет никакого пространства. По идее, туда нужно было бы добавить вьюшку, но вместе с ней при скролле появлялась бы белая полоса. Я заранее ожидал такое поведение.

Решение

Расширение AppBarLayout. Также сохраняю appbarBottom, чтобы нарисовать дугу: сохраняю нижнюю координату ContentView и увеличиваю AppBarLayout до конца блока Arc. Так нам будет удобнее рисовать. (Спойлер: это решение в итоге со мной сыграет злую шутку.)

Расширяю AppBarLayout
class RoundedAppBarLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppBarLayout(context, attrs, defStyleAttr) {
 
    init {
        LayoutInflater.from(context).inflate(R.layout.appbar_rounded_default, this, true)
        initView()
    }
 
    private fun initView() {
        this.post {
            layoutParams = CoordinatorLayout.LayoutParams(width, height + arcHeight)
            appbarBottom = height.toFloat()
            appbarHalfWidth = width / 2f
        }
    }
}
Добавляю ContentChild и Toolbar для нормального отображения контента
    override fun onFinishInflate() {
        super.onFinishInflate()
        if (childCount > 1) {
            val contentChild = this.getChildAt(1)
            removeView(contentChild)
 
            val layoutParams = CollapsingToolbarLayout.LayoutParams(contentChild.layoutParams)
            with(contentChild) {
                layoutParams.setMargins(
                    marginStart,
                    marginTop + toolbarHeight,
                    marginEnd,
                    marginBottom
                )
            }
            contentChild.setBackgroundColor(context.getColor(R.color.accent2))
            contentChild.layoutParams = layoutParams
 
            collapsingToolbar.addView(contentChild)
        }
Рисую дугу
private fun drawArc(canvas: Canvas) {
        val scale = bottom / height.toFloat()
 
        arcPath.apply {
            reset()
            moveTo(0f, appbarBottom)
            quadTo(
                appbarHalfWidth,
                //divide by 2 due to arc drawing logic
                (appbarBottom + (arcHeight * 2)) * scale,
                width.toFloat(),
                appbarBottom
            )
        }
        canvas.drawPath(arcPath, arcPaint).apply {
            invalidate()
        }
    }

Нужно просчитать расстояние, когда AppBarLayout полностью заскроллен и недоскролен. То есть scale = 1 — это AppBarLayout раскрыт полностью, а scale = 0 — схлопнут. И рисую саму дугу. Я столкнулся здесь с очень интересной логикой: если указывать по y-координате, что крайняя точка дуги должна быть на 48 пикселях, то вершина этой точки будет на 24 пикселях — в два раза меньше. В итоге нужно домножать на два — решил я, не разобравшись с причиной.

Сейчас, во время написания статьи, я понимаю, что использовал функцию, которая рисует не дугу, а кривую Безье. И она строится не через крайнюю точку, а посередине. Вместо Path можно использовать Arc. Реализация останется той же, только без домножения на 2. 

Весь код класса AppBarLayout
class RoundedAppBarLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppBarLayout(context, attrs, defStyleAttr) {
 
    private var appbarBottom = 0f
    private var appbarHalfWidth = 0f
 
    //region toolbarArc
    private val arcHeight = DimensUtils.convertDpToPixel(48f, context)
    private val toolbarHeight = DimensUtils.convertDpToPixel(56f, context)
 
    private val arcPaint = Paint().apply {
        color = context.getColor(R.color.accent2)
        style = Paint.Style.FILL
        isAntiAlias = true
    }
    private val arcPath = Path()
    //endregion
 
    init {
        LayoutInflater.from(context).inflate(R.layout.appbar_default, this, true)
        initView()
    }
 
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        drawArc(canvas)
    }
 
    override fun onFinishInflate() {
        super.onFinishInflate()
        if (childCount > 1) {
            val contentChild = this.getChildAt(1)
            removeView(contentChild)
 
            val layoutParams = CollapsingToolbarLayout.LayoutParams(contentChild.layoutParams)
            with(contentChild) {
                layoutParams.setMargins(
                    marginStart,
                    marginTop + toolbarHeight,
                    marginEnd,
                    marginBottom
                )
            }
            contentChild.setBackgroundColor(context.getColor(R.color.accent2))
            contentChild.layoutParams = layoutParams
 
            collapsingToolbar.addView(contentChild)
        }
 
        val toolbar = LayoutInflater.from(context)
            .inflate(R.layout.toolbar_default, collapsingToolbar, false)
        removeView(toolbar)
        collapsingToolbar.addView(toolbar)
    }
 
    private fun initView() {
        setBackgroundColor(context.getColor(R.color.transparent))
        outlineProvider = null
 
        this.post {
            layoutParams = CoordinatorLayout.LayoutParams(width, height + arcHeight)
            appbarBottom = height.toFloat()
            appbarHalfWidth = width / 2f
        }
    }
 
    private fun drawArc(canvas: Canvas) {
        val scale = bottom / height.toFloat()
 
        arcPath.apply {
            reset()
            moveTo(0f, appbarBottom)
            quadTo(
                appbarHalfWidth,
                //divide by 2 due to arc drawing logic
                (appbarBottom + (arcHeight * 2)) * scale,
                width.toFloat(),
                appbarBottom
            )
        }
        canvas.drawPath(arcPath, arcPaint).apply {
            invalidate()
        }
    }
}

№4. Белая полоса при коллапсе

Только я порадовался, что всё хорошо работает, как внезапно обнаружился костыль в основном коде. Когда ты прокручиваешь экран, то буквально за пару пикселей до конца появляется белый прямоугольник.

Начинаешь разбирать — понимаешь, что это проделки Behavior c AppBarLayout. Так как я обновил ширину всего AppBarLayout (там теперь AppBarLayout и ширина нашей дуги), то когда он коллапсится, Layout смотрит, что AppBarLayout должен быть по высоте как Toolbar и дуга, поэтому выделяет место именно под такой размер. 

Изначально я думал, что так как AppBarLayout имеет transparent background, то никаких проблем не будет, но когда AppBarLayout коллапсится, цвет background уже не играет роли. 

Во время разработки я не понял, почему в AppBarLayout свободное пространство остаётся белым, а не transparent. В коде AppBarLayout я не смог найти этого. Возможно, это уже тонкости работы с CoordinatorLayout.

Решение

Я долго думал, как это исправить, и решил применить метод «инкостыляции». Он заключается в том, что я пишу свой кастомный Behavior, и когда он устанавливается в AppBarLayout, то я вызываю метод setRoundedAppBarBehavior. По сути я просто добавляю дополнительный margin, чтобы он мог отображаться поверх AppBarLayout. 

Код
class RoundedAppBarBehavior(context: Context, attrs: AttributeSet) :
    AppBarLayout.ScrollingViewBehavior(context, attrs) {
 
    private var isInstalled = false
 
    override fun layoutDependsOn(
        parent: CoordinatorLayout,
        child: View,
        dependency: View
    ): Boolean {
        if (!isInstalled && dependency is BaseAppBarLayout) {
            isInstalled = true
            child.setRoundedAppBarBehavior()
        }
        return super.layoutDependsOn(parent, child, dependency)
    }
}
 
fun View.setRoundedAppBarBehavior() {
    if (this is ViewGroup) {
        val arcHeight = resources.getDimensionPixelSize(R.dimen.toolbar_arc_height)
 
        this.post {
            clipToPadding = false
 
            val lp = CoordinatorLayout.LayoutParams(layoutParams)
            lp.behavior = AppBarLayout.ScrollingViewBehavior()
            lp.setMargins(marginStart, marginTop - arcHeight, marginEnd, marginBottom)
            layoutParams = lp
        }
    }
}

Всё снова заработало, ура!

Как выглядит проблема и решение

№5. AppBar без контента не скроллится

Оказалось, что каждого AppBarLayout может быть контент внутри. И AppBarLayout думает: контента внутри меня никакого нет, так зачем мне скроллиться?

Решение

Разделение базового класса на два дочерних: RoundedAppBarLayour и SimpleRoundedAppBarLayout. В первый можно поместить контент, а второй останется без него.

Код базового класса AppBarLayout и 2 наследуемых SimpleRoundedAppBarLayout и RoundedAppBarLayout
abstract class BaseAppBarLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppBarLayout(context, attrs, defStyleAttr) {
 
    var title: String? = null
        set(value) {
            field = value
            titleToolbar?.text = value ?: ""
        }
    var navigationIcon: Int? = null
 
    var isEnableCollapsingBehaviour: Boolean = false
 
    // The value from which the arc is drawn
    protected var appbarBottom = 0f
    protected var appbarHalfWidth = 0f
 
    //region toolbarArc
    protected val arcHeight = context.resources.getDimensionPixelSize(R.dimen.toolbar_arc_height)
    protected val toolbarHeight = context.resources.getDimensionPixelSize(R.dimen.toolbar_height)
    val nonScrollableHeight = arcHeight + toolbarHeight
 
    protected val arcPaint = Paint().apply {
        color = context.getColor(R.color.accent2)
        style = Paint.Style.FILL
        isAntiAlias = true
    }
    protected val arcPath = Path()
    //endregion
 
    var contentChild: View? = null
 
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        drawArc(canvas)
    }
 
    fun updateToolbarTitleMargin(
        marginStart: Int = 0,
        marginTop: Int = 0,
        marginEnd: Int = 0,
        marginBottom: Int = 0
    ) {
        titleToolbar.layoutParams = Toolbar.LayoutParams(titleToolbar.layoutParams).apply {
            setMargins(marginStart, marginTop, marginEnd, marginBottom)
        }
    }
 
    protected fun initToolbar(toolbar: Toolbar) {
        navigationIcon?.let {
            toolbar.navigationIcon = ContextCompat.getDrawable(context, it)
            toolbar.setNavigationOnClickListener {
                (context as? Activity)?.onBackPressed()
            }
        }
 
        toolbar.findViewById<TextView>(R.id.titleToolbar)?.let { textView ->
            textView.text = title
 
            if (navigationIcon != null) {
                textView.layoutParams = Toolbar.LayoutParams(textView.layoutParams).apply {
                    setMargins(0, 0, 64.dp, 0)
                }
            }
        }
    }
 
    protected fun initAttrs(attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyle: Int = 0) {
        context.withStyledAttributes(
            attrs,
            R.styleable.RoundedAppBarLayout,
            defStyleAttr,
            defStyle
        ) {
            val defValue = -1
 
            title = getString(R.styleable.RoundedAppBarLayout_ral_title)
            getResourceId(R.styleable.RoundedAppBarLayout_ral_navigate_icon, defValue).apply {
                if (this != defValue) navigationIcon = this
            }
            isEnableCollapsingBehaviour =
                getBoolean(R.styleable.RoundedAppBarLayout_ral_collapsing_behaviour, false)
        }
    }
 
    protected open fun drawArc(canvas: Canvas) {
        val scale = (bottom - nonScrollableHeight) / (height - nonScrollableHeight).toFloat()
 
        contentChild?.alpha = max(0f, scale - (1 - scale))
 
        if (isEnableCollapsingBehaviour) collapsingBehaviour(scale)
 
        arcPath.apply {
            reset()
            moveTo(0f, appbarBottom)
            quadTo(
                appbarHalfWidth,
                //multiply by 2 due to arc drawing logic
                appbarBottom + (arcHeight * 2) * scale,
                width.toFloat(),
                appbarBottom
            )
        }
        canvas.drawPath(arcPath, arcPaint).apply {
            invalidate()
        }
    }
 
    private fun collapsingBehaviour(scale: Float) {
        titleToolbar?.alpha = 1 - scale
        contentChild?.y = (toolbarHeight - arcHeight).toFloat().dp
    }
}
 
class SimpleRoundedAppBarLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : BaseAppBarLayout(context, attrs, defStyleAttr) {
 
    init {
        LayoutInflater.from(context).inflate(R.layout.appbar_rounded_simple, this, true)
        initView()
        initAttrs(attrs, defStyleAttr)
    }
 
    override fun onFinishInflate() {
        super.onFinishInflate()
 
        toolbar.setBackgroundColor(ContextCompat.getColor(context, R.color.accent2))
        initToolbar(toolbar)
    }
 
    override fun drawArc(canvas: Canvas) {
        val scale = (bottom - toolbarHeight) / (height - toolbarHeight).toFloat()
        val startDrawingHeight = appbarBottom + arcHeight * (1 - scale)
 
        arcPath.apply {
            reset()
            moveTo(0f, startDrawingHeight)
            quadTo(
                appbarHalfWidth,
                //multiply by 2 due to arc drawing logic
                appbarBottom + (arcHeight * 2) * scale,
                width.toFloat(),
                startDrawingHeight
            )
        }
        canvas.drawPath(arcPath, arcPaint).apply {
            invalidate()
        }
    }
 
    private fun initView() {
        setBackgroundColor(context.getColor(R.color.transparent))
        backgroundTintMode = PorterDuff.Mode.OVERLAY
        outlineProvider = null
 
        this.post {
            appbarBottom = height - arcHeight.toFloat()
            appbarHalfWidth = width / 2f
        }
    }
}
 
class RoundedAppBarLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : BaseAppBarLayout(context, attrs, defStyleAttr) {
 
    private var isArcDrawn = false
 
    init {
        LayoutInflater.from(context).inflate(R.layout.appbar_rounded_default, this, true)
        initView()
        initAttrs(attrs, defStyleAttr)
    }
 
    override fun onFinishInflate() {
        super.onFinishInflate()
        if (childCount > 1) addContentView()
 
        val toolbar = LayoutInflater.from(context)
            .inflate(R.layout.toolbar_default, collapsingToolbar, false)
        removeView(toolbar)
        collapsingToolbar.addView(toolbar)
 
        (toolbar as? Toolbar)?.let { initToolbar(it) }
    }
 
    private fun initView() {
        setBackgroundColor(context.getColor(R.color.transparent))
        backgroundTintMode = PorterDuff.Mode.OVERLAY
        outlineProvider = null
 
        this.post {
            layoutParams = CoordinatorLayout.LayoutParams(width, height + arcHeight)
            appbarBottom = height.toFloat()
            appbarHalfWidth = width / 2f
            isArcDrawn = true
        }
    }
 
    private fun addContentView() {
        val contentChild = this.getChildAt(1)
        removeView(contentChild)
 
        val layoutParams = CollapsingToolbarLayout.LayoutParams(contentChild.layoutParams)
            layoutParams.setMargins(
                marginStart,
                marginTop + toolbarHeight,
                marginEnd,
                marginBottom
            )
 
        contentChild.setBackgroundColor(context.getColor(R.color.accent2))
        contentChild.layoutParams = layoutParams
        this.contentChild = contentChild
 
        collapsingToolbar.addView(contentChild)
    }
}

Тот Toolbar, в котором нет контента, теперь можно сделать цветом. Делаем это потому, что теперь не будет эффекта затемнения, как было на гифке в первом пункте (так как контента, кроме Toolbar, нет), поэтому мы можем просто задать цвет, и всё будет отлично работать. Здесь показываю, как в итоге было сделано.

Схема AppBar
Схема AppBar

Здесь я уже мог добавить ArcView — саму вьюшку дуги — и не беспокоиться с тем, что там не будет транспарентного расстояния. Теперь CollapsingToolbar будет transparent, Toolbar остаётся с цветом, а AppBarLayout не требует расширения.

Сравнение структур Layout'ов
Сравнение структур Layout'ов

Единственное, что нам пришлось изменить в коде, — изменить высоту и переделать рисование дуги, потому что так как там нет Child, то не надо ничего затемнять. 

XML-код для SimpleRoundedAppBarLayout и RoundedAppBarLayout
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/transparent"
    android:orientation="vertical"
    tools:parentTag="com.google.android.material.appbar.AppBarLayout">
 
    <com.google.android.material.appbar.CollapsingToolbarLayout
        android:id="@+id/collapsingToolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/transparent"
        app:layout_scrollFlags="scroll|snap|exitUntilCollapsed">
 
        <View
            android:id="@+id/arcView"
            android:layout_width="match_parent"
            android:layout_height="48dp"
            android:layout_marginTop="56dp"
            android:background="@color/transparent" />
 
        <include
            android:id="@+id/toolbar"
            layout="@layout/toolbar_default"
            android:layout_width="match_parent"
            android:layout_height="56dp"
            android:layout_gravity="top"
            app:layout_collapseMode="pin" />
 
    </com.google.android.material.appbar.CollapsingToolbarLayout>
</merge>
 
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/transparent"
    tools:parentTag="com.google.android.material.appbar.AppBarLayout">
 
    <com.google.android.material.appbar.CollapsingToolbarLayout
        android:id="@+id/collapsingToolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/accent2"
        app:layout_scrollFlags="scroll|snap|exitUntilCollapsed">
 
    </com.google.android.material.appbar.CollapsingToolbarLayout>
</merge>

№6. При скрытии и показе AppBarLayout в активности появляется белая полоса

Далее у нас возникли последствия метода «инкостыляции» при выставлении visibility. 

У нас есть одна активность и на ней много фрагментов. Сверху активности находится RoundedAppBarLayout, а внизу — контейнер для фрагментов. Если во время переключения между фрагментами менять visibility, то появляется белая полоса. 

Причина — у дочернего контейнера для фрагмента выставлялся margin. Белая полоса — это и есть тот самый margin.

При скрытии и показе AppBarLayout в активности появлялась белая полоса
При скрытии и показе AppBarLayout в активности появлялась белая полоса

Решение

Дополнить кастомный Behavior и добавить в него VisibilityListener.

Новый код Behavoir
class RoundedAppBarBehavior(context: Context, attrs: AttributeSet) :
    AppBarLayout.ScrollingViewBehavior(context, attrs) {
 
    private var isInstalled = false
 
    override fun layoutDependsOn(
        parent: CoordinatorLayout,
        child: View,
        dependency: View
    ): Boolean {
        if (!isInstalled && dependency is BaseAppBarLayout) {
            isInstalled = true
            child.setRoundedAppBarBehavior()
            addVisibilityListener(dependency, child)
        }
        return super.layoutDependsOn(parent, child, dependency)
    }
 
    private fun addVisibilityListener(appBarLayout: AppBarLayout, child: View) {
        var isVisibleSaved = appBarLayout.isVisible
 
        val visibilityListener = ViewTreeObserver.OnGlobalLayoutListener {
            if (isVisibleSaved != appBarLayout.isVisible) {
                isVisibleSaved = appBarLayout.isVisible
 
                if (isVisibleSaved) {
                    child.setRoundedAppBarBehavior()
                } else {
                    child.removeRoundedAppBarBehavior()
                }
            }
        }
 
        appBarLayout.viewTreeObserver.addOnGlobalLayoutListener(visibilityListener)
        appBarLayout.doOnDetach {
            appBarLayout.viewTreeObserver.removeOnGlobalLayoutListener(visibilityListener)
        }
    }
}

Здесь я подписываюсь на GlobalLayout, когда doOnDetach — отписываюсь от него. Если он видим, тогда я добавляю ему RoundedBehavior, то есть добавляю margin, если он невидим — я убираю их. Это делается для того, чтобы не возникали утечки памяти при работе в приложении.

№7. Белое пространство при нескроллящемся контенте в Child

Прилетел ещё один баг. В профиле клиента появляется белая полоса, если child содержит нескроллящийся контент. 

Когда я работал со списками, применялся мой Behavior — весь список прокручивался, и всё работало отлично. Как только в Layout, на который применялся behaviour, появилась статическая часть, то margin сверху оставался.

Решение

Добавить кастомный ScrollListener. Он сделал отображение AppBarLayout ещё более красивым, в скролл — плавным.

Обновлённый код
class RoundedAppBarBehavior(context: Context, attrs: AttributeSet) :
    AppBarLayout.ScrollingViewBehavior(context, attrs) {
 
    private var isInstalled = false
 
    private val arcHeight = context.resources.getDimensionPixelSize(R.dimen.toolbar_arc_height)
 
    override fun layoutDependsOn(
        parent: CoordinatorLayout,
        child: View,
        dependency: View
    ): Boolean {
        if (!isInstalled && dependency is BaseAppBarLayout) {
            isInstalled = true
            child.setRoundedAppBarBehavior()
            addVisibilityListener(dependency, child)
            addOnScrollListener(dependency, child)
        }
        return super.layoutDependsOn(parent, child, dependency)
    }
 
    private fun addOnScrollListener(appBarLayout: BaseAppBarLayout, child: View) {
        val scrollListener = AppBarLayout.OnOffsetChangedListener { _, _ ->
            val divider = (appBarLayout.height - appBarLayout.nonScrollableHeight).toFloat()
            val scale =
                if (divider == 0f) 1f else (appBarLayout.bottom - appBarLayout.nonScrollableHeight) / divider
 
            child.setPadding(
                child.paddingLeft,
                max(0, (arcHeight * scale).toInt()),
                child.paddingEnd,
                child.paddingBottom
            )
        }
 
        appBarLayout.addOnOffsetChangedListener(scrollListener)
        appBarLayout.doOnDetach {
            appBarLayout.removeOnOffsetChangedListener(scrollListener)
        }
    }
 
    private fun addVisibilityListener(appBarLayout: AppBarLayout, child: View) {
        var isVisibleSaved = appBarLayout.isVisible
 
        val visibilityListener = ViewTreeObserver.OnGlobalLayoutListener {
            if (isVisibleSaved != appBarLayout.isVisible) {
                isVisibleSaved = appBarLayout.isVisible
 
                if (isVisibleSaved) {
                    child.setRoundedAppBarBehavior()
                } else {
                    child.removeRoundedAppBarBehavior()
                }
            }
        }
 
        appBarLayout.viewTreeObserver.addOnGlobalLayoutListener(visibilityListener)
        appBarLayout.doOnDetach {
            appBarLayout.viewTreeObserver.removeOnGlobalLayoutListener(visibilityListener)
        }
    }
}

Здесь вычисления примерно такие же, как с рисованием дуги: рассчитывается, рассчитывается коэффициент скролла scale, где 0 — это полностью скрытая дуга, а 1 — полностью раскрытая дуга. Теперь расстояние контента до дуги будет точно рассчитываться во время скролла.

Как выглядит проблема и решение

Как сделать за полчаса то, с чем я провозился сутки

Я прикидывал много вариантов, как можно было бы сделать похожее поведение с CoordinatorLayout, но, к сожалению, в AppBarLayout нельзя сделать прокрутку у контента, который находится ниже статического. Даже если добавить два AppBarLayout, они всё равно будут криво работать. 

Когда мы изначально анализировали, как сделать анимацию на главном экране, то казалось, что с задачей не возникнет никаких проблем. Только в ходе проекта начали находить разные баги в том методе, который мы выбрали, и поняли, что есть много кейсов, где стандартное поведение не работает.

С каждым новым багом мы вносили новые изменения, дописывали и доделывали. Уже в финале мы осознали, что лучше было выбрать другое решение. Тогда я бы смог реализовать задачу за полчаса вместо суток.

На какие решения я предлагаю обратить внимание:

1) Написать свой наследник FrameLayout

С ним можно делать всё, что угодно: и на канвасе рисовать, и двигать вьюшки, как хочется.

2) Использовать MotionLayout

Используя MotionLayout, реализовать дугу в AppBarLayout можно буквально за 30 минут! Для этого достаточно указать все нужные элементы на фрагменте или активности, а потом, используя встроенные инструменты, задать поведение при анимации. 

???? Пример реализации дуги в Motion Layout — на Гитхабе.

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