Animation sous Android: transitions fluides des fragments à l'intérieur de la feuille inférieure

Une énorme quantité de documentation et d'articles a été écrite sur un élément visuel important des applications - l'animation. Malgré cela, nous avons pu nous retrouver dans des problèmes et rencontré des difficultés dans sa mise en œuvre.



Cet article traite du problème et de l'analyse des options pour sa solution. Je ne vais pas vous donner une solution miracle contre tous les monstres, mais je vais vous montrer comment vous pouvez en étudier un en particulier afin de créer une balle spécifiquement pour lui. Je vais analyser cela en utilisant un exemple de la façon dont nous avons fait de l'animation des fragments changeants devenir amis avec la feuille du bas.







Diamond Checkout: Contexte



Diamond Checkout est le nom de code de notre projet. Sa signification est très simple - réduire le temps passé par le client à la dernière étape de la commande. Alors que l'ancienne version nécessitait au moins quatre clics sur deux écrans pour passer une commande (et chaque nouvel écran est une potentielle perte de contexte de la part de l'utilisateur), la «caisse en diamant» ne nécessite idéalement qu'un seul clic sur un écran.





Comparaison de l'ancienne et de la nouvelle caisse



Nous appelons le nouvel écran "rideau" entre nous. Dans l'image, vous pouvez voir comment nous avons reçu la tâche des concepteurs. Cette solution de conception est standard, elle est connue sous le nom de Bottom Sheet, décrit dans Material Design (y compris pour Android) et est utilisé dans diverses variantes dans de nombreuses applications. Google nous propose deux options de mise en œuvre prêtes à l'emploi: modale et persistante. La différence entre ces approches a été décrit dans de nombreux , de nombreux articles.





Nous avons décidé que notre rideau serait modal et était proche d'une fin heureuse, mais l'équipe de conception était sur ses gardes et n'a pas laissé cela se produire si facilement.



Voyez quelles animations impressionnantes sur iOS . Faisons la même chose?



Nous ne pouvions pas refuser un tel défi! D'accord, je plaisante sur "les concepteurs ont soudainement proposé de faire de l'animation", mais la partie sur iOS est vraie.



Les transitions standard entre les écrans (c'est-à-dire l'absence de transitions) paraissaient, sans être trop maladroites, mais n'atteignaient pas le titre de «caisse de diamant». Bien que, de qui je plaisante, c'était vraiment terrible:





Ce que nous avons «prêt à l'emploi»



Avant de passer à la description de l'implémentation de l'animation, je vais vous dire à quoi ressemblaient les transitions avant.



  1. Le client a cliqué sur le champ d'adresse de la pizzeria -> en réponse, le fragment "Pickup" a été ouvert. Il s'est ouvert en plein écran (comme prévu) avec un saut brutal, tandis que la liste des pizzerias est apparue avec un léger retard.
  2. Lorsque le client a appuyé sur "Retour" -> le retour à l'écran précédent s'est produit avec un saut brutal.
  3. Lorsque j'ai cliqué sur le champ du mode de paiement -> à partir du bas, le fragment "Mode de paiement" s'est ouvert d'un bond. La liste des moyens de paiement est apparue avec un retard; quand ils sont apparus, l'écran s'est agrandi avec un saut.
  4. Lorsque vous appuyez sur "Retour" -> revenir en arrière avec un saut brusque.


Le retard dans l'affichage des données est dû au fait qu'elles sont chargées sur l'écran de manière asynchrone. Il sera nécessaire d'en tenir compte à l'avenir.



Quel est, en fait, le problème: là où le client se sent bien, là nous avons des limites



Les utilisateurs n'aiment pas qu'il y ait trop de mouvements brusques sur l'écran. C'est distrayant et déroutant. De plus, vous voulez toujours voir une réponse douce à votre action, et non des convulsions.



Cela nous a conduit à une limitation technique: nous avons décidé que nous ne pouvons pas fermer la feuille de fond actuelle et en montrer une nouvelle à chaque changement d'écran, et il serait également mauvais d'afficher plusieurs feuilles de fond les unes au-dessus des autres. Ainsi, dans le cadre de notre implémentation (chaque écran est un nouveau fragment), vous ne pouvez faire qu'une seule feuille de fond, qui doit se déplacer le plus facilement possible en réponse aux actions de l'utilisateur.



Cela signifie que nous aurons un conteneur de fragments qui est dynamique en hauteur (puisque tous les fragments ont des hauteurs différentes), et nous devons animer son changement de hauteur.



Balisage préliminaire



L'élément racine du "rideau" est très simple - il s'agit simplement d'un fond rectangulaire avec des coins arrondis en haut et un conteneur dans lequel les fragments sont placés.



<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/dialog_gray200_background"
    >
 
  <androidx.fragment.app.FragmentContainerView
      android:id="@+id/container"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      />
 
</FrameLayout>


Et le fichier dialog_gray200_background.xml ressemble à ceci:



<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
  <item>
    <shape android:shape="rectangle">
      <solid android:color="@color/gray200" />
      <corners android:bottomLeftRadius="0dp" android:bottomRightRadius="0dp" android:topLeftRadius="10dp" android:topRightRadius="10dp" />
    </shape>
  </item>
</selector>


Chaque nouvel écran est un fragment séparé, les fragments sont remplacés en utilisant la méthode replace, tout est standard ici.



Premières tentatives d'implémentation de l'animation



animateLayoutChanges



Souvenons-nous de l'ancienne magie elfique animateLayoutChanges , qui est en fait la LayoutTransition par défaut. Bien que animateLayoutChanges ne soit pas du tout conçu pour modifier des fragments, on espère que cela aidera avec l'animation de hauteur. De plus, FragmentContainerView ne prend pas en charge animateLayoutChanges, nous le changeons donc en bon vieux FrameLayout.



<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/dialog_gray200_background"
    >
 
  <FrameLayout
      android:id="@+id/container"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:animateLayoutChanges="true"
      />
 
</FrameLayout>


Courir:



animateLayoutChanges



Comme vous pouvez le voir, la modification de la hauteur du conteneur est vraiment animée lors du changement de fragments. Aller à l'écran Pickup semble correct, mais le reste laisse beaucoup à désirer.



L'intuition suggère que ce chemin mènera à un œil contrarié du concepteur, alors nous annulons nos changements et essayons autre chose.



setCustomAnimations



FragmentTransaction vous permet de définir une animation décrite au format xml à l'aide de la méthode setCustomAnimation . Pour ce faire, dans les ressources, créez un dossier appelé "anim" et ajoutez-y quatre fichiers d'animation:



to_right_out.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:toXDelta="100%" />
</set>


to_right_in.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:fromXDelta="-100%" />
</set>


to_left_out.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:toXDelta="-100%" />
</set>


to_left_in.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:fromXDelta="100%" />
</set>


Et puis nous définissons ces animations dans une transaction:



fragmentManager
    .beginTransaction()
    .setCustomAnimations(R.anim.to_left_in, R.anim.to_left_out, R.anim.to_right_in, R.anim.to_right_out)
    .replace(containerId, newFragment)
    .addToBackStack(newFragment.tag)
    .commit()


On obtient le résultat suivant:





setCustomAnimation



Ce que nous avons avec cette implémentation:



  • Cela s'est déjà amélioré - vous pouvez voir comment les écrans se remplacent en réponse à l'action de l'utilisateur.
  • Mais il y a encore un saut dû aux différentes hauteurs des fragments. Cela est dû au fait que lorsque vous déplacez des fragments dans la hiérarchie, il n'y a qu'un seul fragment. C'est lui qui ajuste la hauteur du conteneur pour lui-même, et le second affiche "comment c'est arrivé".
  • Il y a toujours un problème avec le chargement asynchrone des données sur les méthodes de paiement - l'écran apparaît d'abord vide, puis se remplit de contenu.


Ce n'est pas bien. Conclusion: vous avez besoin d'autre chose.



Ou peut-être essayer quelque chose de soudain: la transition d'élément partagé



La plupart des développeurs Android connaissent Shared Element Transition. Cependant, bien que cet outil soit très flexible, de nombreuses personnes rencontrent des problèmes pour l'utiliser et ne sont donc pas très friandes de l'utiliser.





Son essence est assez simple - nous pouvons animer la transition d'éléments d'un fragment à un autre. Par exemple, nous pouvons déplacer l'élément sur le premier fragment (appelons-le «l'élément initial») avec animation à la place de l'élément sur le deuxième fragment (nous appellerons cet élément «l'élément final»), tout en fondu le reste des éléments du premier fragment et en montrant le deuxième fragment avec fondu. L'élément qui doit s'animer d'un fragment à un autre est appelé élément partagé.



Pour définir l'élément partagé, nous avons besoin de:



  • marquez l'élément de début et l'élément de fin avec l'attribut transitionName avec la même valeur;
  • spécifiez sharedElementEnterTransition pour le deuxième morceau.


Que faire si vous utilisez la vue racine du fragment comme élément partagé? Peut-être que Shared Element Transition n'a pas été inventé pour cela. Cependant, si vous y réfléchissez, il est difficile de trouver un argument pour lequel cette solution ne fonctionnera pas. Nous voulons animer l'élément de départ jusqu'à l'élément de fin entre deux fragments. Je ne vois aucune contradiction idéologique. Essayons ça!



Pour chaque fragment qui se trouve à l'intérieur du "rideau", pour la vue racine, spécifiez l'attribut transitionName avec la même valeur:



<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:transitionName="checkoutTransition"
    >


Important: cela fonctionnera car nous utilisons REPLACE dans la transaction de bloc. Si vous utilisez ADD (ou utilisez ADD et masquez l'extrait de code précédent avec previousFragment.hide () [ne faites pas cela]), alors transitionName devra être défini dynamiquement et effacé après la fin de l'animation. Cela doit être fait, car à un moment donné dans la hiérarchie de vues actuelle, il ne peut pas y avoir deux vues avec le même nom de transition. Cela peut être fait, mais ce sera mieux si vous pouvez vous passer d'un tel hack. Si vous avez vraiment besoin d'utiliser ADD, vous pouvez trouver l'inspiration pour l'implémentation dans cet article.


Ensuite, vous devez spécifier la classe Transition, qui sera responsable du déroulement de notre transition. Tout d'abord, vérifions ce qui est hors de la boîte - utilisez AutoTransition .



newFragment.sharedElementEnterTransition = AutoTransition()


Et nous devons définir l'élément partagé que nous voulons animer dans la transaction de fragment. Dans notre cas, ce sera la vue racine du fragment:



fragmentManager
    .beginTransaction()
    .apply{
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        addSharedElement(currentFragment.requireView(), currentFragment.requireView().transitionName)
        setReorderingAllowed(true)
      }
    }
    .replace(containerId, newFragment)
    .addToBackStack(newFragment.tag)
    .commit()


Important: veuillez noter que transitionName (comme l'ensemble de l'API Transition) est disponible à partir d'Android Lollipop.


Voyons ce qui se passe:





AutoTransition



Transition a fonctionné, mais ça a l'air moyen. En effet, lors d'une transaction de bloc, seul le nouveau bloc se trouve dans la hiérarchie de vue. Ce fragment étire ou réduit le conteneur à sa taille et seulement après cela, il commence à s'animer à l'aide d'une transition. C'est pour cette raison que nous ne voyons l'animation que lorsque le nouveau fragment est plus haut que le précédent.



La mise en œuvre standard ne nous convenant pas, que devons-nous faire? Bien sûr, vous devez tout réécrire dans Flutter et écrire votre propre Transition!



Écrire votre transition



Transition est une classe de l' API Transition qui est responsable de la création d'une animation entre deux scènes (Scene). Les principaux éléments de cette API:



  • Scene est la disposition des éléments sur l'écran à un moment donné (layout) et le ViewGroup dans lequel l'animation a lieu (sceneRoot).
  • La scène de début est la scène à l'heure de début.
  • La scène de fin est la scène au point final dans le temps.
  • Transition est une classe qui collecte les propriétés des scènes de début et de fin et crée un animateur pour animer entre elles.


Nous utiliserons quatre méthodes dans la classe Transition:



  • fun getTransitionProperties (): Array. Cette méthode doit renvoyer un ensemble de propriétés qui seront animées. À partir de cette méthode, vous devez renvoyer un tableau de chaînes (clés) sous une forme libre, l'essentiel est que les méthodes captureStartValues ​​et captureEndValues ​​(décrites ci-dessous) écrivent des propriétés avec ces clés. Un exemple suivra.
  • fun captureStartValues(transitionValues: TransitionValues). layout' . , , , .
  • fun captureEndValues(transitionValues: TransitionValues). , layout' .
  • fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator?. , , . , , .


Transition



  1. , Transition.



    @TargetApi(VERSION_CODES.LOLLIPOP)
    class BottomSheetSharedTransition : Transition {
    	@Suppress("unused")
    	constructor() : super()
     
    	@Suppress("unused")
    	constructor(
        	  context: Context?,
        	   attrs: AttributeSet?
    	) : super(context, attrs)
    }
    , Transition API Android Lollipop.
  2. getTransitionProperties.



    View, PROP_HEIGHT, ( ) :



    companion object {
      private const val PROP_HEIGHT = "heightTransition:height"
     
      private val TransitionProperties = arrayOf(PROP_HEIGHT)
    }
     
    override fun getTransitionProperties(): Array<String> = TransitionProperties
  3. captureStartValues.



    View, transitionValues. transitionValues.values ( Map) c PROP_HEIGHT:



    override fun captureStartValues(transitionValues: TransitionValues) {
      transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
    }


    , . , . , - . « » , , . , . :



    override fun captureStartValues(transitionValues: TransitionValues) {
      //    View...
      transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
     
      // ...      
      transitionValues.view.parent
        .let { it as? View }
        ?.also { view ->
            view.updateLayoutParams<ViewGroup.LayoutParams> {
                height = view.height
            }
        }
     
    }
  4. captureEndValues.



    , View. . . , . . , , , . — view, , . :



    override fun captureEndValues(transitionValues: TransitionValues) {
      //     View
      transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
    }


    getViewHeight:



    private fun getViewHeight(view: View): Int {
      //   
      val deviceWidth = getScreenWidth(view)
     
      //  View      
      val widthMeasureSpec = MeasureSpec.makeMeasureSpec(deviceWidth, MeasureSpec.EXACTLY)
      val heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
     
      return view
          // 
          .apply { measure(widthMeasureSpec, heightMeasureSpec) }
          //   
          .measuredHeight
          //  View       ,     
          .coerceAtMost(getScreenHeight(view))
    }
     
    private fun getScreenHeight(view: View) =
      getDisplaySize(view).y - getStatusBarHeight(view.context)
     
    private fun getScreenWidth(view: View) =
      getDisplaySize(view).x
     
    private fun getDisplaySize(view: View) =
      Point().also {
        (view.context.getSystemService(
            Context.WINDOW_SERVICE
        ) as WindowManager).defaultDisplay.getSize(it)
      }
     
    private fun getStatusBarHeight(context: Context): Int =
      context.resources
          .getIdentifier("status_bar_height", "dimen", "android")
          .takeIf { resourceId -> resourceId > 0 }
          ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) }
          ?: 0


    , , — .
  5. . Fade in.



    , . . «BottomSheetSharedTransition», :



    private fun prepareFadeInAnimator(view: View): Animator =
       ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f)
     
  6. . .



    , :



    private fun prepareHeightAnimator(
        startHeight: Int,
        endHeight: Int,
        view: View
    ) = ValueAnimator.ofInt(startHeight, endHeight)
        .apply {
            val container = view.parent.let { it as View }
            
            //    
            addUpdateListener { animation ->
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = animation.animatedValue as Int
                }
            }
        }


    ValueAnimator . , . , . , , . , WRAP_CONTENT. , :



    private fun prepareHeightAnimator(
        startHeight: Int,
        endHeight: Int,
        view: View
    ) = ValueAnimator.ofInt(startHeight, endHeight)
        .apply {
            val container = view.parent.let { it as View }
            
            //    
            addUpdateListener { animation ->
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = animation.animatedValue as Int
                }
            }
            
            //      WRAP_CONTENT 
            doOnEnd {
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = ViewGroup.LayoutParams.WRAP_CONTENT
                }
            }
        }


    , .
  7. . createAnimator.



    override fun createAnimator(
        sceneRoot: ViewGroup?,
        startValues: TransitionValues?,
        endValues: TransitionValues?
    ): Animator? {
        if (startValues == null || endValues == null) {
            return null
        }
     
        val animators = listOf<Animator>(
            prepareHeightAnimator(
                startValues.values[PROP_HEIGHT] as Int,
                endValues.values[PROP_HEIGHT] as Int,
                endValues.view
            ),
            prepareFadeInAnimator(endValues.view)
        )
     
        return AnimatorSet()
            .apply {
                interpolator = FastOutSlowInInterpolator()
                duration = ANIMATION_DURATION
                playTogether(animators)
            }
    }
  8. .



    Transititon'. , . , . «createAnimator» . ?



    • Fade' , .
    • «captureStartValues» , , WRAP_CONTENT.


    , . : , , Transition'. :



    companion object {
        private const val PROP_HEIGHT = "heightTransition:height"
        private const val PROP_VIEW_TYPE = "heightTransition:viewType"
     
        private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)
    }
     
    override fun getTransitionProperties(): Array<String> = TransitionProperties
     
    override fun captureStartValues(transitionValues: TransitionValues) {
        //    View...
        transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
        transitionValues.values[PROP_VIEW_TYPE] = "start"
     
        // ...      
        transitionValues.view.parent
            .let { it as? View }
            ?.also { view ->
                view.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = view.height
                }
            }
     
    }
     
    override fun captureEndValues(transitionValues: TransitionValues) {
        //     View
        transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
        transitionValues.values[PROP_VIEW_TYPE] = "end"
    }
    


    , «PROP_VIEW_TYPE», «captureStartValues» «captureEndValues» . , !
  9. Transition.



    newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()




Pour que l'animation démarre à l'heure et soit bonne, il vous suffit de reporter la transition entre les fragments (et, par conséquent, l'animation) jusqu'à ce que les données soient chargées. Pour ce faire, appelez la méthode postponeEnterTransition à l'intérieur du fragment . N'oubliez pas d'appeler startPostponedEnterTransition lorsque vous avez terminé les longues tâches de chargement de données . Je suis sûr que vous connaissiez cette astuce, mais cela ne fait pas de mal de vous le rappeler une fois de plus.



Tous ensemble: ce qui s'est passé à la fin



Avec le nouveau BottomSheetSharedTransition et l'utilisation de postponeEnterTransition lors du chargement de données de manière asynchrone, nous avons obtenu l'animation suivante:



Transition prête



Sous le spoiler, il y a une classe prête à l'emploi BottomSheetSharedTransition
package com.maleev.bottomsheetanimation
 
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.annotation.TargetApi
import android.content.Context
import android.graphics.Point
import android.os.Build
import android.transition.Transition
import android.transition.TransitionValues
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.AccelerateInterpolator
import androidx.core.animation.doOnEnd
import androidx.core.view.updateLayoutParams
 
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
class BottomSheetSharedTransition : Transition {
 
    @Suppress("unused")
    constructor() : super()
 
    @Suppress("unused")
    constructor(
        context: Context?,
        attrs: AttributeSet?
    ) : super(context, attrs)
 
    companion object {
        private const val PROP_HEIGHT = "heightTransition:height"
 
        // the property PROP_VIEW_TYPE is workaround that allows to run transition always
        // even if height was not changed. It's required as we should set container height
        // to WRAP_CONTENT after animation complete
        private const val PROP_VIEW_TYPE = "heightTransition:viewType"
        private const val ANIMATION_DURATION = 400L
 
        private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)
    }
 
    override fun getTransitionProperties(): Array<String> = TransitionProperties
 
    override fun captureStartValues(transitionValues: TransitionValues) {
        //    View...
        transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
        transitionValues.values[PROP_VIEW_TYPE] = "start"
 
        // ...      
        transitionValues.view.parent
            .let { it as? View }
            ?.also { view ->
                view.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = view.height
                }
            }
 
    }
 
    override fun captureEndValues(transitionValues: TransitionValues) {
        //     View
        transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
        transitionValues.values[PROP_VIEW_TYPE] = "end"
    }
 
    override fun createAnimator(
        sceneRoot: ViewGroup?,
        startValues: TransitionValues?,
        endValues: TransitionValues?
    ): Animator? {
        if (startValues == null || endValues == null) {
            return null
        }
 
        val animators = listOf<Animator>(
            prepareHeightAnimator(
                startValues.values[PROP_HEIGHT] as Int,
                endValues.values[PROP_HEIGHT] as Int,
                endValues.view
            ),
            prepareFadeInAnimator(endValues.view)
        )
 
        return AnimatorSet()
            .apply {
                duration = ANIMATION_DURATION
                playTogether(animators)
            }
    }
 
    private fun prepareFadeInAnimator(view: View): Animator =
        ObjectAnimator
            .ofFloat(view, "alpha", 0f, 1f)
            .apply { interpolator = AccelerateInterpolator() }
 
    private fun prepareHeightAnimator(
        startHeight: Int,
        endHeight: Int,
        view: View
    ) = ValueAnimator.ofInt(startHeight, endHeight)
        .apply {
            val container = view.parent.let { it as View }
 
            //    
            addUpdateListener { animation ->
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = animation.animatedValue as Int
                }
            }
 
            //      WRAP_CONTENT
            doOnEnd {
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = ViewGroup.LayoutParams.WRAP_CONTENT
                }
            }
        }
 
    private fun getViewHeight(view: View): Int {
        //   
        val deviceWidth = getScreenWidth(view)
 
        //  View      
        val widthMeasureSpec =
            View.MeasureSpec.makeMeasureSpec(deviceWidth, View.MeasureSpec.EXACTLY)
        val heightMeasureSpec =
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
 
        return view
            // :
            .apply { measure(widthMeasureSpec, heightMeasureSpec) }
            //   :
            .measuredHeight
            //  View       ,     :
            .coerceAtMost(getScreenHeight(view))
    }
 
    private fun getScreenHeight(view: View) =
        getDisplaySize(view).y - getStatusBarHeight(view.context)
 
    private fun getScreenWidth(view: View) =
        getDisplaySize(view).x
 
    private fun getDisplaySize(view: View) =
        Point().also { point ->
            view.context.getSystemService(Context.WINDOW_SERVICE)
                .let { it as WindowManager }
                .defaultDisplay
                .getSize(point)
        }
 
    private fun getStatusBarHeight(context: Context): Int =
        context.resources
            .getIdentifier("status_bar_height", "dimen", "android")
            .takeIf { resourceId -> resourceId > 0 }
            ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) }
            ?: 0
}




Lorsque nous avons une classe Transition prête à l'emploi, son application se résume à des étapes simples:



Étape 1. Dans une transaction de fragment, ajoutez un élément partagé et définissez la transition:



private fun transitToFragment(newFragment: Fragment) {
    val currentFragmentRoot = childFragmentManager.fragments[0].requireView()
 
    childFragmentManager
        .beginTransaction()
        .apply {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                addSharedElement(currentFragmentRoot, currentFragmentRoot.transitionName)
                setReorderingAllowed(true)
 
                newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()
            }
        }
        .replace(R.id.container, newFragment)
        .addToBackStack(newFragment.javaClass.name)
        .commit()
}


Étape 2. Dans le balisage des fragments (le fragment actuel et le suivant), qui doivent être animés à l'intérieur du BottomSheetDialogFragment, définissez le transitionName:



<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:transitionName="checkoutTransition"
    >


C'est tout, la fin.



Cela aurait-il pu être fait différemment?



Il existe toujours plusieurs options pour résoudre un problème. Je veux mentionner d'autres approches possibles que nous n'avons pas essayées:



  • Abandonnez les fragments, utilisez un fragment avec de nombreuses vues et animez des vues spécifiques. Cela vous donne plus de contrôle sur l'animation, mais vous perdez les avantages des fragments: prise en charge de la navigation native et gestion du cycle de vie prête à l'emploi (vous devrez l'implémenter vous-même).
  • MotionLayout. MotionLayout , , , .
  • . , , . Bottom Sheet Bottom Sheet .
  • Bottom Sheet . — .
GitHub. Android- ( ) .




All Articles