Как декларативно описать коллапсирующий Toolbar
Хочу представить решение того, как можно описать 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к звезд на Гитхабе.
Поправить верстку для этого решения можно, но меня смущает другое. Некоторые вью управляются через OnOffsetChangedListener, некоторые — через Behavior, что-то работает из коробки. Разработчику, чтобы понять всю картину, придется пробежаться по множеству классов, и если для новой вью придется добавить поведение, которое зависит от других Behavior-ов и от вью, которые изменяются в OnOffsetChangedListener, могут вырасти костыли и баги на ровном месте
Кроме того, в данном примере не показано, как быть, если в тулбар будут добавляться дополнительные элементы, которые влияют на высоту этого тулабара.
В гифке в начале статьи видно, как по нажатию на кнопку скрывается TextView — и NestedScroll подтягивается выше, чтобы не возникало пустого пространства).
Как это сделать? Решения, которые первыми приходят на ум, — написать еще один CoordinatorLayout.Behavior для NestedScroll (сохранив логику базового AppBarLayout.Behavior) или засунуть тулбар в AppBarLayout и менять его на OnOffsetChangedListener. Я пробовал оба решения, и получался завязанный на детали реализации код, с которым довольно сложно будет разобраться кому-то другому и нельзя будет переиспользовать.
Буду рад, если кто-то поделится примером, где такая логика реализована «чисто», а пока покажу свое решение. Идея в том, чтобы иметь возможность декларативно описать в одном месте, какие вьюхи и как должны себя вести.
Как выглядит апи
Итак, для создания CoordinatorLayout.Behavior нужно:
- унаследовать BehaviorByRules;
- переопределить методы, возвращающие AppBarLayout, CollapsingToolbarLayout и длину скролла (высоту AppBarLayout).
- переопределить метод setUpViews — описать правила, как вью будет себя вести при измененнии проскролла аппБара.
TopInfoBehavior для тулбара из гифки в начале статьи будет выглядеть так (далее в статье объясню, как это работает):
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
}
}
Как это работает
Задача сводится к написанию правил:
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:
/**
* [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, об этом расскажу ниже):
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. Код можно посмотреть на гитхабе.