Comment décrire de manière déclarative une barre d'outils repliée



Je veux présenter une solution à la façon dont CollapsingToolbar peut être décrite, en mettant l'accent sur la lisibilité du code. L'article n'expliquera pas ce qu'est et comment écrire votre CoordinatorLayout.Behavior. Si le lecteur est intéressé à comprendre cela, il existe de nombreux articles, y compris ceux sur le Habr . Si vous ne voulez pas comprendre, ça va: j'ai essayé d'obtenir l'orthographe de CollapsingToolbar afin de pouvoir résumer de CoordinatorLayout.Behavior et OnOffsetChangedListener.

Termes


  • Barre d'outils - un ensemble de vues que nous voulons afficher en haut de l'écran (pas android.widget.Toolbar).
  • NestedScroll - toute vue déroulante qui peut être associée à AppBarLayout (RecyclerView, NestedScrollView).

Pourquoi aviez-vous besoin d'écrire votre décision


J'ai regardé plusieurs approches sur «Internet», et presque toutes ont été construites comme suit:

  1. Définit une hauteur fixe pour AppBarLayout.
  2. CoordinatorLayout.Behavior est écrit, dans lequel, avec certains calculs (la hauteur de la vue mise en cache est ajoutée au bas d'une autre vue et moins la marge est multipliée par le défilement, calculée ici), ils changent une sorte de vue.
  3. D'autres vues changent dans le OnOffsetChangedListener de AppBarLayout.

Voici un exemple de comportement avec l'approche décrite, 2,5k étoiles sur Github.

En attente

Réalité: enfilez votre OnePlus

Vous pouvez corriger la mise en page de cette solution, mais quelque chose d'autre m'embrouille. Certaines vues sont gérées via OnOffsetChangedListener, d'autres via Behavior, quelque chose fonctionne par défaut. Pour comprendre l'image dans son ensemble, le développeur devra passer par de nombreuses classes, et si pour une nouvelle vue, il est nécessaire d'ajouter un comportement qui dépend des autres comportements et des vues qui changent dans OnOffsetChangedListener, les béquilles et les bogues peuvent sortir du bleu

En outre, cet exemple ne montre pas quoi faire si des éléments supplémentaires sont ajoutés à la barre d'outils qui affectent la hauteur de cette barre d'outils.

Dans le gif au début de l'article, vous pouvez voir comment TextView est masqué en cliquant sur le bouton - et NestedScroll est tiré plus haut pour qu'aucun espace vide n'apparaisse).

GIF à nouveau

Comment faire Les solutions qui vous viennent à l'esprit en premier sont d'écrire un autre CoordinatorLayout.Behavior pour NestedScroll (en gardant la logique de l'AppBarLayout.Behavior sous-jacent) ou de coller la barre d'outils dans AppBarLayout et de la changer en OnOffsetChangedListener. J'ai essayé les deux solutions, et il s'est avéré que le code était lié aux détails d'implémentation, ce qui serait assez difficile à comprendre pour quelqu'un d'autre et ne pourrait pas être réutilisé.

Je serais heureux si quelqu'un partage un exemple où une telle logique est implémentée «proprement», mais pour l'instant je vais montrer ma solution. L'idée est de pouvoir décrire de manière déclarative en un seul endroit quelles vues et comment elles doivent se comporter.

À quoi ressemble l'api?


Donc, pour créer CoordinatorLayout.Behavior, vous avez besoin de:

  • hériter BehaviorByRules;
  • remplacer les méthodes qui renvoient AppBarLayout, CollapsingToolbarLayout et la longueur de défilement (hauteur AppBarLayout).
  • remplacer la méthode setUpViews - décrivez les règles de comportement de la vue lorsque la barre de défilement de la barre d'applications change.

TopInfoBehavior pour la barre d'outils du gif au début de l'article ressemblera à ceci (plus loin dans l'article, je vais expliquer comment cela fonctionne):

Disposition

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 } } 


Disposition XML (suppression des attributs évidents pour la lisibilité)
 <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> 


Comment ça marche


La tâche consiste à écrire les règles:

 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) } 

Tout est clair ici - une valeur flottante de 0 à 1 entre, reflétant le pourcentage du défilement ActionBar, une vue arrive et son état initial. Il semble plus intéressant BaseBehaviorRule - une règle dont les autres règles de base sont héritées.

 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) 

Pour les règles de base, la plage de valeurs (min, max) et l'interpolateur sont déterminés. Cela suffit pour décrire presque tous les comportements.

Supposons que nous voulons définir l'alpha de notre vue dans une plage de 0,5 à 0,9. Nous voulons également que la vue de défilement devienne rapidement transparente d'abord, puis le taux de changement diminuera.
La règle ressemblera à ceci:

 BRuleAlpha(min = 0.5f, max = 0.9f, interpolator = DecelerateInterpolator()) 

Et voici l'implémentation de 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 } } 


Et enfin, le code BehaviorByRules. Pour ceux qui ont écrit leur comportement, tout devrait être évident (sauf pour ce qui est à l'intérieur de onMeasureChild, je vais en parler ci-dessous):

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) } } } 


Alors quoi de neuf avec onMeasureChild?

Cela est nécessaire pour résoudre le problème que j'ai décrit ci-dessus: si une partie de la barre d'outils disparaît, NestedScroll devrait se déplacer plus haut. Pour le faire monter plus haut, vous devez réduire la hauteur de CollapsingToolbarLayout.

Il existe une autre méthode non évidente - canUpdateHeight. Il est nécessaire pour permettre à l'héritier de définir une règle lorsque vous ne pouvez pas modifier la hauteur. Par exemple, si la vue dont dépend la hauteur est actuellement masquée. Je ne suis pas sûr que cela couvrira tous les cas, mais si quelqu'un a des idées sur la façon de faire mieux, veuillez écrire dans un commentaire ou dans un message personnel.

Râteau sur lequel vous pouvez marcher lorsque vous travaillez avec CollapsingToolbarLayout


  • Lorsque vous changez de vue, vous devez éviter onLayout. Par exemple, vous ne devez pas modifier layoutParams ou textSize dans un BehaviorRule, sinon les performances seront considérablement réduites.
  • Si vous souhaitez travailler avec la barre d'outils via OnOffsetChangedListener, onLayout est encore plus dangereux - la méthode onOffsetChanged se déclenchera indéfiniment.
  • CoordinatorLayout.Behavior ne doit pas dépendre de la vue (layoutDependsOn), qui peut entrer en visibilité GONE. Lorsque cette vue revient à View.VISIBLE, Behavior ne réagit pas.
  • Si la barre d'outils se trouve en dehors de AppBarLayout, pour empêcher la barre d'outils de la bloquer, vous devez ajouter l'attribut android: translationZ = "5dp" au ViewGroup parent de la barre d'outils.

En conclusion


Nous avons une solution qui vous permet d'esquisser rapidement votre CollapsingToolbarLayout avec une logique relativement facile à lire et à modifier. Toutes les règles et dépendances sont formées dans le cadre d'une classe - CoordinatorLayout.Behavior. Le code peut être consulté sur le github .

Source: https://habr.com/ru/post/fr426369/


All Articles