折りたたみツールバーを宣言的に記述する方法





コードの読みやすさを重視して、CollapsingToolbarの記述方法のソリューションを紹介したいと思います。 この記事では、CoordinatorLayout.Behaviorの概要と作成方法については説明しません。 読者がこれを理解することに興味があるなら、 Habrの記事を含む多くの記事があります。 理解したくない場合は、大丈夫です。CoordinatorLayout.BehaviorおよびOnOffsetChangedListenerから抽象化できるように、CollapsingToolbarのスペルを取得しようとしました。



規約





なぜ決定を書く必要があったのですか



「インターネット」でいくつかのアプローチを検討しましたが、ほとんどすべてが次のように構築されました。



  1. AppBarLayoutの固定の高さを設定します。
  2. CoordinatorLayout.Behaviorは、いくつかの計算(キャッシュされたビューの高さを別のビューの下部に追加し、マージンにここで計算したスクロールを掛けたもの)で、ある種のビューを変更します。
  3. AppBarLayoutのOnOffsetChangedListenerで他のビューが変更されます。


以下に Githubの2.5kの星で説明したアプローチでの動作の例を示します。



待ってる






現実:OnePlusを装着する






このソリューションのレイアウトを修正できますが、他の何かが私を悩ませます。 一部のビューはOnOffsetChangedListenerによって管理され、一部のビューは動作によって管理されます。 全体像を理解するために、開発者は多くのクラスを通過する必要があり、新しいビューのために、他の動作とOnOffsetChangedListenerで変化するビューに依存する動作を追加する必要がある場合、松葉杖とバグは急成長する可能性があります



さらに、この例では、このツールバーの高さに影響する要素がツールバーに追加された場合の対処方法を示していません。



記事の冒頭のgifで、ボタンをクリックしてTextViewが非表示になっていることを確認できます。NestedScrollを引き上げると、空のスペースがなくなります。



再びGIF






どうやってやるの? 最初に思い浮かぶ解決策は、別のCoordinatorLayout.BehaviorをNestedScrollに書き込む(基になるAppBarLayout.Behaviorのロジックを保持する)か、AppBarLayoutにツールバーを貼り付けて、OnOffsetChangedListenerに変更することです。 両方のソリューションを試しましたが、実装の詳細に関連付けられたコードが判明しました。これは、他の誰かが理解するのは非常に難しく、再利用できませんでした。



そのようなロジックが「きれいに」実装されている例を誰かが共有してくれれば嬉しいが、今のところは私のソリューションを紹介する。 考え方は、どのビューをどのように動作させるかを1か所宣言的に説明できるようにすることです。



APIはどのように見えますか?



したがって、CoordinatorLayout.Behaviorを作成するには次が必要です。





記事の冒頭にある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)
      
      





基本的なルールでは、値の範囲(最小、最大)および補間器が決定されます。 これは、ほとんどすべての動作を説明するのに十分です。



ビューのアルファを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の高さを低くする必要があります。



もう1つの非自明なメソッド、canUpdateHeightがあります。 高さを変更できないときに相続人がルールを設定できるようにするために必要です。 たとえば、高さが依存するビューが現在非表示になっている場合。 これですべてのケースがカバーされるかどうかはわかりませんが、それを改善するためのアイデアがあれば、コメントまたはPMでお書きください。



CollapsingToolbarLayoutで作業するときにステップできるレーキ





結論として



比較的簡単に読み取りおよび変更できるロジックを使用して、CollapsingToolbarLayoutをすばやくスケッチできるソリューションがあります。 すべてのルールと依存関係は、1つのクラス-CoordinatorLayout.Behaviorのフレームワーク内で形成されます。 コードはgithubで表示できます。



All Articles