如何以声明方式描述折叠的工具栏



我想介绍一种如何描述CollapsingToolbar的解决方案,重点是代码的可读性。 本文不会解释什么是以及如何编写CoordinatorLayout.Behavior。 如果读者有兴趣了解这一点,那么有很多文章,包括有关Habr的文章 。 如果您不想理解,也可以:我尝试获取CollapsingToolbar的拼写,以便可以从CoordinatorLayout.Behavior和OnOffsetChangedListener中抽象出来。

条款


  • 工具栏-我们要在屏幕顶部显示的一组视图(不是android.widget.Toolbar)。
  • NestedScroll-可以与AppBarLayout相关联的任何可滚动视图(RecyclerView,NestedScrollView)。

你为什么需要写你的决定


我查看了“ Internet”上的几种方法,几乎​​所有方法都是按以下方式构建的:

  1. 为AppBarLayout设置固定高度。
  2. 编写了CoordinatorLayout.Behavior,其中通过一些计算(将缓存的视图高度添加到另一个视图的底部,减去边距乘以滚动,在此计算)后,它们会更改某种视图。
  3. 其他视图在AppBarLayout的OnOffsetChangedListener中更改。

这是上述方法的行为示例,在Github上得分为2.5k星。

等待中

现实:戴上OnePlus

您可以更正此解决方案的布局,但其他原因使我感到困惑。 有些视图是通过OnOffsetChangedListener管理的,有些是通过Behavior的,有些是开箱即用的。 为了理解整个图景,开发人员将必须经历许多类,并且如果对于新视图,有必要添加依赖于其他行为的行为以及依赖于OnOffsetChangedListener中已更改的视图的行为,则拐杖和错误可能会突然消失。

此外,此示例未显示如果将其他元素添加到工具栏上而影响该工具栏的高度,该怎么办。

在本文开头的gif中,您可以通过单击按钮查看如何隐藏TextView-并将NestedScroll拉到更高的位置,以便没有空白空间)。

再次GIF

怎么做? 首先想到的解决方案是为NestedScroll编写另一个CoordinatorLayout.Behavior(保留基础AppBarLayout.Behavior的逻辑)或将工具栏粘贴到AppBarLayout中并将其更改为OnOffsetChangedListener。 我尝试了两种解决方案,结果发现代码与实现细节相关,其他人很难理解并且无法重用。

如果有人分享“干净”地实现这种逻辑的示例,我将感到很高兴,但是现在我将展示我的解决方案。 这个想法是要能够在一个地方声明的方式描述哪些视图及其行为方式。

api是什么样的?


因此,要创建CoordinatorLayout.Behavior,您需要:

  • 继承BehaviorByRules;
  • 覆盖返回AppBarLayout,CollapsingToolbarLayout和滚动长度(AppBarLayout高度)的方法。
  • 覆盖setUpViews方法-描述当appBar的滚动条更改时视图的行为规则。

本文开头的gif中的工具栏的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) } 

此处的所有内容都很清楚-浮点值从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) 

对于基本规则,确定值的范围(最小,最大)和内插器。 这足以描述几乎所有行为。

假设我们要将视图的Alpha值设置为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代码。 对于那些写了“行为”的人来说,一切都应该是显而易见的(除了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。 需要它以便您可以在不能更改高度时允许继承人设置规则。 例如,如果当前隐藏高度所依赖的视图。 我不确定这是否会涵盖所有情况,但是如果有人对如何做得更好有任何想法,请在评论中或在个人留言中写下。

在使用CollapsingToolbarLayout时可以踩到的耙子


  • 更改视图时,应避免使用onLayout。 例如,您不应在BehaviorRule内更改layoutParams或textSize,否则会严重降低性能。
  • 如果要通过OnOffsetChangedListener使用工具栏,则onLayout会更加危险-onOffsetChanged方法将无限期触发。
  • CoordinatorLayout.Behavior不应依赖于视图(layoutDependsOn),该视图可以进入可见性GONE。 当该视图返回到View.VISIBLE时,“行为”将不起作用。
  • 如果工具栏位于AppBarLayout之外,则为了防止工具栏阻塞它,您需要向工具栏的父级ViewGroup添加android属性:translationZ =“ 5dp”。

总结


我们有一个解决方案,可让您使用相对易于阅读和修改的逻辑快速勾勒出CollapsingToolbarLayout。 所有规则和依赖关系都在一个类的框架-CoordinatorLayout.Behavior中形成。 可以在github上查看代码。

Source: https://habr.com/ru/post/zh-CN426369/


All Articles