- PVSM.RU - https://www.pvsm.ru -

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


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

Как это сделать? Решения, которые первыми приходят на ум, — написать еще один CoordinatorLayout.Behavior для NestedScroll (сохранив логику базового AppBarLayout.Behavior) или засунуть тулбар в AppBarLayout и менять его на OnOffsetChangedListener. Я пробовал оба решения, и получался завязанный на детали реализации код, с которым довольно сложно будет разобраться кому-то другому и нельзя будет переиспользовать.
Буду рад, если кто-то поделится примером, где такая логика реализована «чисто», а пока покажу свое решение. Идея в том, чтобы иметь возможность декларативно описать в одном месте, какие вьюхи и как должны себя вести.
Итак, для создания CoordinatorLayout.Behavior нужно:
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<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
}
}
<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)
}
Для базовых правил определяется размах значений (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<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 {
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<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 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 с логикой, которую относительно легко будет читать и изменять. Все правила и зависимости формируются в рамках одного класса — CoordinatorLayout.Behavior. Код можно посмотреть на гитхабе [3].
Автор: arturdumchev
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/android/295813
Ссылки в тексте:
[1] на хабре: https://habr.com/post/270121/
[2] пример Behavior: https://github.com/saulmm/CoordinatorBehaviorExample
[3] гитхабе: https://github.com/Liverm0r/BehavioRule
[4] Источник: https://habr.com/post/426369/?utm_campaign=426369
Нажмите здесь для печати.