Как декларативно описать коллапсирующий Toolbar

8g0fwn_juqzn2hdlqfsnpxncz1w.gif

Хочу представить решение того, как можно описать CollapsingToolbar, с акцентом на читаемости кода. В статье не будет объясняться, что такое и как написать свой CoordinatorLayout.Behavior. Если читателю интересно в этом разобраться, есть много статей, в том числе на хабре. Если разбираться не хочется — ничего страшного: я постарался вынести написание CollapsingToolbar так, чтобы можно было абстрагироваться от CoordinatorLayout.Behavior и OnOffsetChangedListener.

Термины


  • Тулбар — набор вьюх, которые хотим отображать вверху экрана (не android.widget.Toolbar).
  • NestedScroll — любая скроллящаяся вью, которую можно связать с AppBarLayout (RecyclerView, NestedScrollView).


Зачем понадобилось писать свое решение


Я просмотрел несколько подходов в «интернетах», и практически все были построены следующим образом:

  1. Задается фиксированная высота для AppBarLayout.
  2. Пишется CoordinatorLayout.Behavior, в котором какими-то вычислениями (закешированная высота view складывается с bottom другого view и за вычетом margin умножается на проскролл, вычисленный здесь же) меняют какую-то вью.
  3. Другие вью меняют в OnOffsetChangedListener AppBarLayout-а.


Вот пример Behavior с описанным подходом, 2.5к звезд на Гитхабе.

Ожидание
7qvv7emfjzhrkn_shib-a0pivxu.gif


Реальность: поставил на свой OnePlus
wnaa4b5bdkbta85lg71cegvwdoa.png


Поправить верстку для этого решения можно, но меня смущает другое. Некоторые вью управляются через OnOffsetChangedListener, некоторые — через Behavior, что-то работает из коробки. Разработчику, чтобы понять всю картину, придется пробежаться по множеству классов, и если для новой вью придется добавить поведение, которое зависит от других Behavior-ов и от вью, которые изменяются в OnOffsetChangedListener, могут вырасти костыли и баги на ровном месте

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

В гифке в начале статьи видно, как по нажатию на кнопку скрывается TextView — и NestedScroll подтягивается выше, чтобы не возникало пустого пространства).

гифка еще раз
8g0fwn_juqzn2hdlqfsnpxncz1w.gif


Как это сделать? Решения, которые первыми приходят на ум, — написать еще один CoordinatorLayout.Behavior для NestedScroll (сохранив логику базового AppBarLayout.Behavior) или засунуть тулбар в AppBarLayout и менять его на OnOffsetChangedListener. Я пробовал оба решения, и получался завязанный на детали реализации код, с которым довольно сложно будет разобраться кому-то другому и нельзя будет переиспользовать.

Буду рад, если кто-то поделится примером, где такая логика реализована «чисто», а пока покажу свое решение. Идея в том, чтобы иметь возможность декларативно описать в одном месте, какие вьюхи и как должны себя вести.

Как выглядит апи


Итак, для создания CoordinatorLayout.Behavior нужно:

  • унаследовать BehaviorByRules;
  • переопределить методы, возвращающие AppBarLayout, CollapsingToolbarLayout и длину скролла (высоту AppBarLayout).
  • переопределить метод setUpViews — описать правила, как вью будет себя вести при измененнии проскролла аппБара.


TopInfoBehavior для тулбара из гифки в начале статьи будет выглядеть так (далее в статье объясню, как это работает):

Макет
_zf1yzlonsivwsvfevlillcopc0.png


TopInfoBehavior.kt
class TopInfoBehavior(
        context: Context?,
        attrs: AttributeSet?
) : BehaviorByRules(context, attrs) {

    override fun calcAppbarHeight(child: View): Int = with(child) {
        return (height + pixels(R.dimen.toolbar_height)).toInt()
    }

    override fun View.provideAppbar(): AppBarLayout = ablAppbar
    override fun View.provideCollapsingToolbar(): CollapsingToolbarLayout
            = ctlToolbar

    override fun View.setUpViews(): List = listOf(
        RuledView(
                viewGroupTopDetails,
                BRuleYOffset(
                    min = pixels(R.dimen.zero),
                    max = pixels(R.dimen.toolbar_height)
                )
        ),
        RuledView(
                textViewTopDetails,
                BRuleAlpha(min = 0.6f, max = 1f),
                BRuleXOffset(
                    min = 0f, max = pixels(R.dimen.big_margin),
                    interpolator =
                    ReverseInterpolator(AccelerateInterpolator())
                ),
                BRuleYOffset(
                    min = pixels(R.dimen.zero), max = pixels(R.dimen.pad),
                    interpolator = ReverseInterpolator(LinearInterpolator())
                ),
                BRuleAppear(0.1f),
                BRuleScale(min = 0.8f, max = 1f)
        ),
        RuledView(
                textViewPainIsTheArse,
                BRuleAppear(isAppearedUntil = GONE_VIEW_THRESHOLD)
        ),
        RuledView(
                textViewCollapsedTop,
                BRuleAppear(0.1f, true)
        ),
        RuledView(
                textViewTop,
                BRuleAppear(isAppearedUntil = GONE_VIEW_THRESHOLD)
        ),
        buildRuleForIcon(ivTop, LinearInterpolator()),
        buildRuleForIcon(ivTop2, AccelerateInterpolator(0.7f)),
        buildRuleForIcon(ivTop3, AccelerateInterpolator())
    )

    private fun View.buildRuleForIcon(
            view: ImageView,
            interpolator: Interpolator
    ) = RuledView(
        view,
        BRuleYOffset(
                min = -(ivTop3.y - tvCollapsedTop.y),
                max = 0f,
                interpolator = DecelerateInterpolator(1.5f)
        ),
        BRuleXOffset(
                min = 0f,
                max = tvCollapsedTop.width.toFloat() + pixels(R.dimen.huge_margin),
                interpolator = ReverseInterpolator(interpolator)
        )
    )

    companion object {
        const val GONE_VIEW_THRESHOLD = 0.8f
    }
}



макет Xml (удалил очевидные атрибуты для читаемости)


    

        

            

        

    

    
    

    
    

    





Как это работает


Задача сводится к написанию правил:

interface BehaviorRule {
    /**
     * @param view to be changed
     * @param details view's data when first attached
     * @param ratio in range [0, 1]; 0 when toolbar is collapsed
     */
    fun manage(ratio: Float, details: InitialViewDetails, view: View)
}


Тут все ясно — приходит float-значение от 0 до 1, отражающее процент проскролла ActionBar, приходит вью и ее первоначальный стейт. Интереснее выглядит BaseBehaviorRule — правило, от которого наследуются другие базовые правила.

abstract class BaseBehaviorRule : BehaviorRule {
    abstract val interpolator: Interpolator
    abstract val min: Float
    abstract val max: Float

    final override fun manage(
            ratio: Float,
            details: InitialViewDetails,
            view: View
    ) {
        val interpolation = interpolator.getInterpolation(ratio)
        val offset = normalize(
                oldValue = interpolation,
                newMin = min, newMax = max
        )
        perform(offset, details, view)
    }

    /**
     * @param offset normalized with range from [min] to [max] with [interpolator]
     */
    abstract fun perform(offset: Float, details: InitialViewDetails, view: View)
}


Для базовых правил определяется размах значений (min, max) и interpolator. Этого хватит для того, чтобы описать практически любое поведение.

Допустим, мы хотим задать альфу для нашего вью в диапазоне 0.5 до 0.9. Также мы хотим, чтобы вначале скролла вью быстро становилась прозрачной, а затем скорость изменений падала.
Правило будет выглядеть так:

BRuleAlpha(min = 0.5f, max = 0.9f, interpolator = DecelerateInterpolator())


А вот реализация BRuleAlpha:

BRuleAlpha.kt
/**
 * [min], [max] — values in range [0, 1]
 */
class BRuleAlpha(
        override val min: Float,
        override val max: Float,
        override val interpolator: Interpolator = LinearInterpolator()
) : BaseBehaviorRule() {
    override fun perform(offset: Float, details: InitialViewDetails, view: View) {
        view.alpha = offset
    }
}

/**
 * Affine transform value form one range into another
 */
fun normalize(
        oldValue: Float,
        newMin: Float, newMax: Float,
        oldMin: Float = 0f, oldMax: Float = 1f
): Float = newMin + ((oldValue - oldMin) * (newMax - newMin)) / (oldMax - oldMin)



И, наконец, код BehaviorByRules. Для тех, кто писал свой Behavior, все должно быть очевидно (кроме того, что внутри onMeasureChild, об этом расскажу ниже):

BehaviorByRules.kt
abstract class BehaviorByRules(
        context: Context?,
        attrs: AttributeSet?
) : CoordinatorLayout.Behavior(context, attrs) {

    private var views: List = emptyList()
    private var lastChildHeight = -1
    private var needToUpdateHeight: Boolean = true

    override fun layoutDependsOn(
            parent: CoordinatorLayout,
            child: View,
            dependency: View
    ): Boolean {
        return dependency is AppBarLayout
    }

    override fun onDependentViewChanged(
            parent: CoordinatorLayout,
            child: View,
            dependency: View
    ): Boolean {
        firstInit(child, dependency)
        val progress = calcProgress(parent)
        views.forEach { performRules(offsetView = it, percent = progress) }
        return true
    }

    override fun onMeasureChild(
            parent: CoordinatorLayout, child: View, parentWidthMeasureSpec: Int,
            widthUsed: Int, parentHeightMeasureSpec: Int, heightUsed: Int
    ): Boolean {

        val canUpdateHeight = canUpdateHeight(calcProgress(parent))
        if (canUpdateHeight) {
            parent.post {
                val newChildHeight = child.height
                if (newChildHeight != lastChildHeight) {
                    lastChildHeight = newChildHeight
                    setUpAppbarHeight(child, parent)
                }
            }
        } else {
            needToUpdateHeight = true
        }
        return super.onMeasureChild(
                parent, child, parentWidthMeasureSpec,
                widthUsed, parentHeightMeasureSpec, heightUsed
        )
    }

    /**
     * If you use fitsSystemWindows=true in your coordinator layout,
     * you will have to include statusBar height in the appbarHeight
     */
    protected abstract fun calcAppbarHeight(child: View): Int
    protected abstract fun View.setUpViews(): List
    protected abstract fun View.provideAppbar(): AppBarLayout
    protected abstract fun View.provideCollapsingToolbar(): CollapsingToolbarLayout

    /**
     * You man not want to update height, if height depends on views, that are currently invisible
     */
    protected open fun canUpdateHeight(progress: Float): Boolean = true

    private fun calcProgress(parent: CoordinatorLayout): Float {
        val appBar = parent.provideAppbar()
        val scrollRange = appBar.totalScrollRange.toFloat()
        val scrollY = Math.abs(appBar.y)
        val scroll = 1 - scrollY / scrollRange
        return when {
            scroll.isNaN() -> 1f
            else -> scroll
        }
    }

    private fun setUpAppbarHeight(child: View, parent: ViewGroup) {
        parent.provideCollapsingToolbar().setHeight(calcAppbarHeight(child))
    }

    private fun firstInit(child: View, dependency: View) {
        if (needToUpdateHeight) {
            setUpAppbarHeight(child, dependency as ViewGroup)
            needToUpdateHeight = false
        }

        if (views.isEmpty()) {
            views = child.setUpViews()
        }
    }

    private fun performRules(offsetView: RuledView, percent: Float) {
        val view = offsetView.view
        val details = offsetView.details
        offsetView.rules.forEach { rule ->
            rule.manage(percent, details, view)
        }
    }
}



Так что там с onMeasureChild?

Это нужно для решения проблемы, о которой писал выше: если какая-то часть тулбара исчезает, NestedScroll должен подъехать выше. Чтобы он подъехал выше, нужно уменьшить высоту CollapsingToolbarLayout.

Есть еще один неочевидный метод — canUpdateHeight. Он нужен, чтобы можно было разрешить наследнику задать правило, когда нельзя менять высоту. Например, если view, от которого зависит высота, в данный момент скрыта. Не уверен, что это покроет все кейсы, но если у кого есть идеи, как сделать лучше, — отпишите, пожалуйста, в комментарии или в личку.

Грабли, на которые можно наступить при работе с CollapsingToolbarLayout


  • Меняя вьюхи, нужно избегать onLayout. Например, не следует менять layoutParams или textSize внутри BehaviorRule, иначе сильно просядет производительность.
  • Если захотите работать с тулбаром через OnOffsetChangedListener, onLayout еще опаснее — метод onOffsetChanged будет триггериться бесконечно.
  • CoordinatorLayout.Behavior не должен зависеть от вью (layoutDependsOn), которая может уйти в visibility GONE. Когда эта вью вернется во View.VISIBLE, Behavior не среагирует.
  • Если тулбар будет находиться вне AppBarLayout, то чтобы его не перекрывал тулбар, нужно в родительскую ViewGroup тулбара добавить атрибут android: translationZ=»5dp».


В заключение


Имеем решение, которое позволяет быстро набросать свой CollapsingToolbarLayout с логикой, которую относительно легко будет читать и изменять. Все правила и зависимости формируются в рамках одного класса — CoordinatorLayout.Behavior. Код можно посмотреть на гитхабе.

© Habrahabr.ru