
Хочу представить решение того, как можно описать CollapsingToolbar, с акцентом на читаемости кода. В статье не будет объясняться, что такое и как написать свой CoordinatorLayout.Behavior. Если читателю интересно в этом разобраться, есть много статей, в том числе на хабре. Если разбираться не хочется — ничего страшного: я постарался вынести написание CollapsingToolbar так, чтобы можно было абстрагироваться от CoordinatorLayout.Behavior и OnOffsetChangedListener.
Термины
- Тулбар — набор вьюх, которые хотим отображать вверху экрана (не android.widget.Toolbar).
- NestedScroll — любая скроллящаяся вью, которую можно связать с AppBarLayout (RecyclerView, NestedScrollView).
Зачем понадобилось писать свое решение
Я просмотрел несколько подходов в «интернетах», и практически все были построены следующим образом:
- Задается фиксированная высота для AppBarLayout.
- Пишется CoordinatorLayout.Behavior, в котором какими-то вычислениями (закешированная высота view складывается с bottom другого view и за вычетом margin умножается на проскролл, вычисленный здесь же) меняют какую-то вью.
- Другие вью меняют в 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. Код можно посмотреть на гитхабе.
