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

от автора

Хочу представить решение того, как можно описать 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),                 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: Number     abstract val max: Number      final override fun manage(             ratio: Float,              details: InitialViewDetails,             view: View     ) {         perform(interpolator.getInterpolation(ratio), details, view)     }      abstract fun perform(ratio: 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(ratio: Float, details: InitialViewDetails, view: View) = with(view) {         this.alpha = normalize(oldValue = ratio, newMin = min, newMax = max)     } }  /**  * 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<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

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

В заключение

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


ссылка на оригинал статьи https://habr.com/post/426369/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *