Хочу представить решение того, как можно описать 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к звезд на Гитхабе.

Ожидание

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

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

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

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

гифка еще раз

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

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

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


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

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

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

Макет

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<RuledView> = listOf(
        RuledView(
                viewGroupTopDetails,
                BRuleYOffset(
                    min = pixels(R.dimen.zero),
                    max = pixels(R.dimen.toolbar_height)
                )
        ),
        RuledView(
                textViewTopDetails,
                BRuleAlpha(min = 0.6f, max = 1f)
                    .workInRange(from = appearedUntil, to = 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 (удалил очевидные атрибуты для читаемости)
<android.support.design.widget.CoordinatorLayout>

    <android.support.design.widget.AppBarLayout
        android:layout_height="wrap_content">

        <android.support.design.widget.CollapsingToolbarLayout
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <android.support.v7.widget.Toolbar
                android:layout_height="@dimen/toolbar_height"
                app:layout_collapseMode="pin"/>

        </android.support.design.widget.CollapsingToolbarLayout>

    </android.support.design.widget.AppBarLayout>

    <!--весь тулбар здесь-->
    <RelativeLayout 
        android:translationZ="5dp"
        app:layout_behavior="TopInfoBehavior"/>

    <android.support.v4.widget.NestedScrollView
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
    </android.support.v4.widget.NestedScrollView>

    <android.support.design.widget.FloatingActionButton
        app:layout_anchor="@id/nesteScroll"
        app:layout_anchorGravity="right"/>

</android.support.design.widget.CoordinatorLayout>


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


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

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)
}

/**
 * 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)

Для базовых правил определяется размах значений (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
    }
}


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

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

    private var views: List<RuledView> = 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 {
        if (views.isEmpty()) views = child.setUpViews()
        val progress = calcProgress(parent)
        views.forEach { performRules(offsetView = it, percent = progress) }
        tryToInitHeight(child, dependency, 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<RuledView>
    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 tryToInitHeight(child: View, dependency: View, scrollPercent: Float) {
        if (needToUpdateHeight && canUpdateHeight(scrollPercent)) {
            setUpAppbarHeight(child, dependency as ViewGroup)
            needToUpdateHeight = false
        }
    }

    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. Код можно посмотреть на гитхабе.

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


  1. alexander_u
    15.10.2018 20:44

    Уважаемый автор! Зачем же Вы так с великим и могучим? Или название вашей статьи Clickbait?
    По пунктам:


    • Термин "коллапс" в русском языке обычно используют в значениях: описания медицинских состояний (например, коллапс легкого); значении краха, разрушения (коллапс экономики) и астрономической/физической лексики (гравитационный коллапс)
    • Окончание "-щий" как правило указывает на происходящее/продолжающееся действие. Таким образом при подобном "переводе" теряется исходный смысл словосочетания Collapsing Toolbar в значений "тулбар, способный/имеющий возможность свернуться". У Вас получается, что он "коллапсирующий", т.е. непосредственно в данный момент времени "коллапсирует" (возможно даже постоянно). Тут нам бы могла помочь возвратная частица "-ся", т.е. показать, что элемент может свернуть(ся) себя, но к счастью, слово "коллапсирующийСЯ" не нашло широкого применения.

    Зачем было заниматься словообразованием, если Вы все равно термин тулбар, написали по-английски? Ведь можно было оставить простой, понятный и общеупотребимый термин CollapsingToolbar.