Como descrever declarativamente uma barra de ferramentas recolhida



Quero apresentar uma solução sobre como o CollapsingToolbar pode ser descrito, com ênfase na legibilidade do código. O artigo não explica o que é e como escrever seu CoordinatorLayout.Behavior. Se o leitor estiver interessado em entender isso, há muitos artigos, incluindo os da Habr . Se você não quer entender, está tudo bem: tentei obter a ortografia da barra de ferramentas Collapsing para que eu pudesse abstrair do CoordinatorLayout.Behavior e OnOffsetChangedListener.

Termos


  • Barra de ferramentas - um conjunto de visualizações que queremos exibir na parte superior da tela (não android.widget.Toolbar).
  • NestedScroll - qualquer exibição rolável que possa ser associada ao AppBarLayout (RecyclerView, NestedScrollView).

Por que você precisou escrever sua decisão


Eu olhei para várias abordagens na “Internet” e quase todas foram construídas da seguinte maneira:

  1. Define uma altura fixa para AppBarLayout.
  2. CoordinatorLayout.Behavior é escrito, no qual, com alguns cálculos (a altura da visualização em cache é adicionada à parte inferior de outra visualização e menos a margem é multiplicada pelo pergaminho, calculado aqui), eles alteram algum tipo de visualização.
  3. Outras visualizações são alteradas no OnOffsetChangedListener do AppBarLayout.

Aqui está um exemplo de Comportamento com a abordagem descrita, 2,5 mil estrelas no Github.

Esperando

Realidade: coloque seu OnePlus

Você pode corrigir o layout desta solução, mas outra coisa me confunde. Algumas visualizações são gerenciadas através do OnOffsetChangedListener, outras através do Behavior, algo funciona imediatamente. Para entender todo o cenário, o desenvolvedor precisará passar por muitas classes e, para uma nova visão, é necessário adicionar um comportamento que depende de outro comportamento e das visões que mudam no OnOffsetChangedListener, muletas e bugs podem crescer do nada

Além disso, este exemplo não mostra o que fazer se elementos adicionais forem adicionados à barra de ferramentas que afetam a altura dessa barra de ferramentas.

No gif no início do artigo, você pode ver como o TextView está oculto, clicando no botão - e o NestedScroll é puxado mais alto para que não haja espaço vazio).

GIF novamente

Como fazer isso? As soluções que vêm à mente primeiro são escrever outro CoordinatorLayout.Behavior para NestedScroll (mantendo a lógica do AppBarLayout.Behavior subjacente) ou colocar a barra de ferramentas no AppBarLayout e alterá-la para OnOffsetChangedListener. Tentei as duas soluções e o código foi vinculado aos detalhes da implementação, o que seria bastante difícil para outra pessoa entender e não poderia ser reutilizado.

Ficaria feliz se alguém compartilhar um exemplo em que essa lógica seja implementada "de maneira limpa", mas por enquanto vou mostrar minha solução. A idéia é ser capaz de descrever declarativamente em um local quais pontos de vista e como eles devem se comportar.

Como é a API?


Portanto, para criar o CoordinatorLayout.Behavior, você precisa:

  • herdar BehaviorByRules;
  • substituir métodos que retornam AppBarLayout, CollapsingToolbarLayout e comprimento de rolagem (AppBarLayout height).
  • substituir o método setUpViews - descreva as regras de como a exibição se comportará quando a barra de rolagem do appBar for alterada.

O TopInfoBehavior para a barra de ferramentas do gif no início do artigo será parecido com este (mais adiante neste artigo, explicarei como funciona):

Layout

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


Layout XML (atributos óbvios removidos para facilitar a leitura)
 <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> 


Como isso funciona


A tarefa é escrever as regras:

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

Tudo está claro aqui - um valor flutuante de 0 a 1 é exibido, refletindo a porcentagem da rolagem do ActionBar, uma visualização é exibida e seu estado inicial. Parece BaseBehaviorRule mais interessante - uma regra da qual outras regras básicas são herdadas.

 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) 

Para as regras básicas, o intervalo de valores (min, max) e o interpolador é determinado. Isso é suficiente para descrever quase qualquer comportamento.

Suponha que desejamos definir o alfa para nossa visualização no intervalo de 0,5 a 0,9. Também queremos que a exibição de rolagem se torne transparente primeiro e, em seguida, a taxa de alteração cairá.
A regra ficará assim:

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

E aqui está a implementação do 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 } } 


E, finalmente, o código BehaviorByRules. Para aqueles que escreveram seu comportamento, tudo deve ser óbvio (exceto o que está dentro do MeasureChild, falarei sobre isso abaixo):

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


Então, o que há com onMeasureChild?

Isso é necessário para resolver o problema que escrevi acima: se alguma parte da barra de ferramentas desaparecer, o NestedScroll deve se mover mais alto. Para aumentar a velocidade, você precisa reduzir a altura do CollapsingToolbarLayout.

Há outro método não óbvio - canUpdateHeight. É necessário para que você possa permitir que o herdeiro defina uma regra quando não puder alterar a altura. Por exemplo, se a vista da qual depende a altura estiver oculta no momento. Não tenho certeza de que isso abranja todos os casos, mas se alguém tiver idéias sobre como melhorar, escreva em um comentário ou em uma mensagem pessoal.

Rake em que você pode pisar ao trabalhar com CollapsingToolbarLayout


  • Ao alterar as visualizações, você deve evitar o onLayout. Por exemplo, você não deve alterar layoutParams ou textSize dentro de uma BehaviorRule, caso contrário, o desempenho será drástico.
  • Se você deseja trabalhar com a barra de ferramentas através do OnOffsetChangedListener, onLayout é ainda mais perigoso - o método onOffsetChanged será acionado indefinidamente.
  • CoordinatorLayout.Behavior não deve depender da visualização (layoutDependsOn), que pode entrar na visibilidade GONE. Quando essa exibição retornar a View.VISIBLE, o comportamento não reagirá.
  • Se a barra de ferramentas estiver fora do AppBarLayout, para impedir que a barra de ferramentas a bloqueie, adicione o atributo android: translationZ = "5dp" ao ViewGroup pai da barra de ferramentas.

Em conclusão


Temos uma solução que permite esboçar rapidamente seu CollapsingToolbarLayout com uma lógica relativamente fácil de ler e modificar. Todas as regras e dependências são formadas na estrutura de uma classe - CoordinatorLayout.Behavior. O código pode ser visualizado no github .

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


All Articles