MotionLayout: les animations sont meilleures, moins de code


Google continue d'améliorer nos vies en publiant de nouvelles bibliothèques et API pratiques. Parmi eux, le nouveau MotionLayout. Compte tenu de l'abondance d'animations dans nos applications, mon collègue Cédric Holtz a immédiatement mis en œuvre l'animation la plus importante de notre application - voter dans la datation - en utilisant la nouvelle API, tout en économisant une énorme quantité de code. Je partage la traduction de son article.

La conférence Google I / O 2019 s'est récemment terminée, au cours de laquelle des mises à jour et les dernières améliorations de notre SDK bien-aimé ont été annoncées. Personnellement, j'ai été particulièrement intéressé par la présentation de Nicholas Road et John Hoford sur la future fonctionnalité de ConstraintLayout. Plus précisément, sur son expansion sous la forme de MotionLayout.

Après la version bêta, je voulais implémenter une animation de rencontres basée sur cette bibliothèque.

Définissons d'abord les termes:

"MotionLayout est un ConstraintLayout qui vous permet d'animer des dispositions entre différents états." - Documentation


Si vous n'avez pas lu une série d'articles de Nicholas Road qui explique les idées clés de MotionLayout, je vous recommande fortement de le lire.

Donc, avec l'introduction faite, voyons maintenant ce que nous voulons obtenir:



Pile de cartes


Nous montrons la carte décalée


Pour commencer, ajoutez MotionLayout au répertoire de disposition, qui contient jusqu'à présent une seule carte supérieure:

<androidx.constraintlayout.motion.widget.MotionLayout android:id="@+id/motionLayout"     xmlns:android="http://schemas.android.com/apk/res/android"     xmlns:app="http://schemas.android.com/apk/res-auto"     android:layout_width="match_parent"     android:layout_height="match_parent"     app:layoutDescription="@xml/scene_swipe"     app:motionDebug="SHOW_ALL">     <FrameLayout         android:id="@+id/topCard"         android:layout_width="0dp"         android:layout_height="0dp" /> </androidx.constraintlayout.motion.widget.MotionLayout> 

Faites attention à cette ligne: app: motionDebug = "SHOW_ALL". Il nous permet d'afficher les informations de débogage, la trajectoire des objets, les états avec le début et la fin de l'animation, ainsi que les progrès en cours. La ligne aide beaucoup au débogage, mais n'oubliez pas de la supprimer avant de l'envoyer au prod: il n'y a pas de rappel pour cela.

Comme vous pouvez le voir, nous n'avons défini aucune restriction pour la vue ici. Ils seront extraits de la scène (MotionScene), que nous définissons maintenant.

Commençons par définir l'état initial: une carte se trouve au centre de l'écran, avec des retraits autour.

 <MotionScene xmlns:android="http://schemas.android.com/apk/res/android"    xmlns:app="http://schemas.android.com/apk/res-auto">    <ConstraintSet android:id="@+id/rest">        <Constraint            android:id="@id/topCard"            android:layout_width="match_parent"            android:layout_height="match_parent"            android:layout_marginBottom="50dp"            android:layout_marginEnd="50dp"            android:layout_marginStart="50dp"            android:layout_marginTop="50dp"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintStart_toStartOf="parent">    </ConstraintSet> </MotionScene> 

Ajouter des ensembles de contraintes (ConstraintSet) pass et like. Ils refléteront l'état de la carte du dessus lorsqu'elle est complètement décalée vers la gauche ou la droite. Nous voulons que la carte s'arrête avant de disparaître de l'écran pour montrer une belle animation qui confirme notre décision.

 <ConstraintSet    android:id="@+id/pass"    app:deriveConstraintsFrom="@+id/rest">    <Constraint        android:id="@id/topCard"        android:layout_width="0dp"        android:layout_height="match_parent"        android:layout_marginBottom="80dp"        android:layout_marginEnd="200dp"        android:layout_marginStart="50dp"        android:layout_marginTop="20dp"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintWidth_percent="0.7" /> </ConstraintSet> <ConstraintSet    android:id="@+id/like"    app:deriveConstraintsFrom="@id/rest">    <Constraint        android:id="@id/topCard"        android:layout_width="0dp"        android:layout_height="match_parent"        android:layout_marginBottom="80dp"        android:layout_marginEnd="50dp"        android:layout_marginStart="200dp"        android:layout_marginTop="20dp"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintWidth_percent="0.7" /> </ConstraintSet> 

Ajoutez les deux ensembles de contraintes à la scène précédente. Ils sont presque identiques, ne se reflétant que des deux côtés de l'écran.

Maintenant, nous avons trois ensembles de restrictions - commencer, aimer et passer. Définissons la transition entre ces états.

Pour ce faire, ajoutez une transition vers la gauche pour le balayage, l'autre vers la droite pour le balayage.

 <Transition    app:constraintSetEnd="@+id/pass"    app:constraintSetStart="@+id/rest"    app:duration="300">    <OnSwipe        app:dragDirection="dragLeft"        app:onTouchUp="autoComplete"        app:touchAnchorId="@id/topCard"        app:touchAnchorSide="left"        app:touchRegionId="@id/topCard" /> </Transition> <Transition    app:constraintSetEnd="@+id/like"    app:constraintSetStart="@+id/rest"    app:duration="300">    <OnSwipe        app:dragDirection="dragRight"        app:onTouchUp="autoComplete"        app:touchAnchorId="@+id/topCard"        app:touchAnchorSide="right"        app:touchRegionId="@id/topCard" /> </Transition> 

Donc, pour la carte du haut, nous définissons l'animation de balayage à gauche et la même chose - miroir pour le balayage à droite.

Ces propriétés aideront à améliorer l'interaction avec notre scène:

  • touchRegionId: depuis que nous avons ajouté un remplissage autour de la carte, nous devons nous assurer que le toucher n'est reconnu que dans la zone de la carte elle-même, et non dans la totalité de MotionLayout. Cela peut être fait en utilisant touchRegionId.
  • onTouchUp: qu'adviendra-t-il de l'animation après la sortie de la carte? Il doit continuer ou revenir à son état initial, donc la saisie semi-automatique est applicable.

Voyons ce qui s'est passé:



La carte disparaît automatiquement de l'écran


Nous allons maintenant travailler sur l'animation, qui commencera lorsque la carte disparaîtra de l'écran.

Nous ajoutons deux autres ensembles ConstraintSet pour chaque état final de nos animations: la carte quitte l'écran à gauche et à droite.

Dans les exemples suivants, je montrerai comment créer l'état similaire et l'état de réussite le répétera en miroir. Un exemple de travail peut être entièrement vu dans le référentiel .

 <ConstraintSet android:id="@+id/offScreenLike">    <Constraint        android:id="@id/topCard"        android:layout_width="0dp"        android:layout_height="match_parent"        android:layout_marginBottom="80dp"        android:layout_marginEnd="50dp"        android:layout_marginTop="20dp"        app:layout_constraintStart_toEndOf="parent"        app:layout_constraintWidth_percent="0.7" />   </ConstraintSet> 

Maintenant, comme dans l'exemple précédent, vous devez déterminer la transition de l'état de balayage à l'état final. La transition devrait fonctionner automatiquement immédiatement après l'animation du balayage. Cela peut être fait en utilisant autoTransition:

 <Transition app:autoTransition="animateToEnd" app:constraintSetEnd="@+id/offScreenLike" app:constraintSetStart="@+id/like" app:duration="150" /> 

Nous avons maintenant une carte magnétique qui peut être glissée sur l'écran!



Animation de la carte inférieure



Faisons maintenant la carte du bas pour créer l'illusion de l'infini du jeu.

Ajoutez une autre carte à la mise en page, similaire à la première:

 <FrameLayout    android:id="@+id/bottomCard"    android:layout_width="0dp"    android:layout_height="0dp"    android:background="@color/colorAccent" /> 

Modifiez le XML pour définir les restrictions qui s'appliquent à cette carte à chaque étape de l'animation:

 <ConstraintSet android:id="@id/rest">    <!-- ... -->    <Constraint android:id="@id/bottomCard">        <Layout            android:layout_width="match_parent"            android:layout_height="match_parent"            android:layout_marginBottom="50dp"            android:layout_marginEnd="50dp"            android:layout_marginStart="50dp"            android:layout_marginTop="50dp" />        <Transform            android:scaleX="0.90"            android:scaleY="0.90" />    </Constraint> </ConstraintSet> <ConstraintSet    android:id="@+id/offScreenLike"    app:deriveConstraintsFrom="@id/like">    <!-- ... -->    <Constraint android:id="@id/bottomCard">        <Transform            android:scaleX="1"            android:scaleY="1" />    </Constraint> </ConstraintSet> 

Pour ce faire, nous pouvons utiliser la propriété ConstraintSet.

Par défaut, chaque nouvel ensemble prend des attributs du MotionLayout parent. Mais en utilisant l'indicateur deriveConstraintsFrom, vous pouvez définir un autre parent pour notre ensemble. Il convient de garder à l'esprit que si nous définissons des contraintes à l'aide de la balise de contrainte, nous redéfinissons toutes les contraintes de l'ensemble parent. Pour éviter cela, vous pouvez définir des attributs spécifiques dans les balises afin que seuls ils soient remplacés.



Dans notre cas, cela signifie que dans l'ensemble de passes, nous ne définissons pas la balise Layout, mais la copions à partir du parent. Cependant, nous remplaçons Transform, par conséquent, nous remplaçons tous les attributs spécifiés dans la balise Transform par les nôtres, dans ce cas, un changement d'échelle.

C'est à quel point il est facile d'utiliser MotionLayout pour ajouter un nouvel élément et l'intégrer de manière transparente aux animations de notre scène.



Rendre l'animation sans fin



Une fois l'animation terminée, la carte du haut ne peut pas être glissée, car elle est maintenant devenue une carte du bas. Pour obtenir une animation sans fin, vous devez échanger des cartes.

Au début, je voulais le faire avec une nouvelle transition:

 <Transition    app:autoTransition="jumpToEnd"    app:constraintSetEnd="@+id/rest"    app:constraintSetStart="@+id/offScreenLike"    app:duration="0" /> 



L'animation entière joue comme il se doit. Nous avons maintenant une pile de cartes que vous pouvez glisser à l'infini!

Après un petit coup, j'ai remarqué quelque chose. La transition vers la fin de l'animation du deck s'arrête lorsque vous touchez la carte. Même si la durée de l'animation est nulle, elle s'arrête toujours, ce qui est mauvais.



J'ai réussi à gagner d'une seule manière - en modifiant par programmation la transition active dans MotionLayout.

Pour ce faire, nous allons définir un rappel à la fin de l'animation. Dès que offScreenLike et offScreenPass sont terminés, nous réinitialisons simplement la transition à l'état de repos et annulons la progression.

 motionLayout.setTransitionListener(object : TransitionAdapter() {    override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) {        when (currentId) {            R.id.offScreenPass,            R.id.offScreenLike -> {                motionLayout.progress = 0f                motionLayout.setTransition(R.id.rest, R.id.like)            }        }    }   }) 

Peu importe la transition que nous définissons, passons ou aimons, lorsque nous glissons, nous passons à la transition souhaitée.



C'est pareil, mais l'animation ne s'arrête pas! Continuons!

Liaison de données


Créez des données de test à afficher sur les cartes. Pour l'instant, nous nous limiterons à changer la couleur d'arrière-plan de chaque carte.

Nous créons un ViewModel avec une méthode svayp qui ne substitue que de nouvelles données. Liez-le à Activity de cette manière:

 val viewModel = ViewModelProviders .of(this) .get(SwipeRightViewModel::class.java) viewModel .modelStream .observe(this, Observer { bindCard(it) }) motionLayout.setTransitionListener(object : TransitionAdapter() { override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) { when (currentId) { R.id.offScreenPass, R.id.offScreenLike -> { motionLayout.progress = 0f motionLayout.setTransition(R.id.rest, R.id.like) viewModel.swipe() } } } }) 

Il reste à informer ViewModel de la fin de l'animation de balayage, et il mettra à jour les données qui sont actuellement affichées.



Icônes contextuelles


Ajoutez deux vues qui, lors du balayage, apparaissent sur l'un des côtés de l'écran (une seule est illustrée ci-dessous, la seconde est mise en miroir).

 <ImageView android:id="@+id/likeIndicator" android:layout_width="0dp" android:layout_height="0dp" /> 

Maintenant, pour les cartes, vous devez définir les états d'animation avec ces vues.

 <ConstraintSet android:id="@id/rest">    <!-- ... -->    <Constraint android:id="@+id/like">        <Layout            android:layout_width="40dp"            android:layout_height="40dp"            app:layout_constraintBottom_toBottomOf="parent"            app:layout_constraintStart_toEndOf="parent"            app:layout_constraintTop_toTopOf="parent" />        <Transform            android:scaleX="0.5"            android:scaleY="0.5" />        <PropertySet android:alpha="0" />    </Constraint>   </ConstraintSet> <ConstraintSet    android:id="@+id/like"    app:deriveConstraintsFrom="@id/rest">    <!-- ... -->    <Constraint android:id="@+id/like">        <Layout            android:layout_width="100dp"            android:layout_height="100dp"            app:layout_constraintBottom_toBottomOf="@id/topCard"            app:layout_constraintEnd_toEndOf="@id/topCard"            app:layout_constraintStart_toStartOf="@id/topCard"            app:layout_constraintTop_toTopOf="@id/topCard" />        <Transform            android:scaleX="1"            android:scaleY="1" />        <PropertySet android:alpha="1" />    </Constraint> </ConstraintSet> 

Il n'est pas nécessaire de définir des restrictions sur les animations qui vont au-delà de l'écran, car elles sont héritées des parents. Et dans notre cas, c'est un état de glissement.

C'est tout ce que nous devons faire. Vous pouvez désormais ajouter très facilement des composants aux chaînes d'animation.



Exécutez l'animation par programme



Nous pouvons créer deux boutons sur les cartes afin que l'utilisateur puisse non seulement glisser, mais contrôler à l'aide des boutons.

Chaque bouton démarre la même animation que le balayage.

Comme d'habitude, abonnez-vous aux clics sur les boutons et démarrez l'animation directement sur l'objet MotionLayout:

 likeButton.setOnClickListener {    motionLayout.transitionToState(R.id.like) } passButton.setOnClickListener {    motionLayout.transitionToState(R.id.pass) } 

Nous devons ajouter des boutons aux cartes du haut et du bas pour que l'animation soit jouée en continu. Cependant, pour la carte inférieure, l'abonnement aux clics n'est pas nécessaire, car il n'est pas visible ou la carte supérieure est animée et nous ne voulons pas l'interrompre.



Un autre excellent exemple de la façon dont MotionLayout gère les changements d'état pour nous. Ralentissons légèrement l'animation:



Regardez la transition que MotionLayout effectue lorsque pass remplace comme. La magie!

Glissez la carte le long de la courbe


Supposons que nous l'aimions si la carte ne se déplace pas en ligne droite, mais en courbe (pour être honnête, je voulais juste essayer de le faire).

Ensuite, vous devez définir KeyPosition pour le mouvement dans les deux directions, de sorte que la trajectoire du mouvement soit courbe par un arc.

Ajoutez ceci à la scène de mouvement:

 <Transition    app:constraintSetEnd="@+id/like"    app:constraintSetStart="@+id/rest"    app:duration="300">    <!-- ... -->    <KeyFrameSet>        <KeyPosition            app:drawPath="path"            app:framePosition="50"            app:keyPositionType="pathRelative"            app:motionTarget="@id/topCard"            app:percentX="0.5"            app:percentY="-0.1" />           </KeyFrameSet> </Transition> 


Maintenant, la carte se déplace le long d'un chemin incurvé non banal. Par magie!

Conclusion


Lorsque vous comparez la quantité de code que j'ai obtenue lors de la création de ces animations avec notre implémentation actuelle d'animations similaires en production, le résultat est stupéfiant.

MotionLayout gère imperceptiblement l'annulation des transitions (par exemple, au toucher), la création de chaînes d'animation, la modification des propriétés pendant les transitions, et bien plus encore. Cet outil change fondamentalement tout, simplifiant considérablement la logique de l'interface utilisateur.

Il y a d'autres choses qui valent la peine d'être travaillées (principalement la désactivation des animations et du défilement bidirectionnel dans RecyclerView), mais je suis sûr que cela peut être résolu.

N'oubliez pas que la bibliothèque est toujours en version bêta, mais qu'elle nous ouvre déjà de nombreuses opportunités intéressantes. Nous attendons avec impatience la sortie de MotionLayout, qui, j'en suis sûr, vous sera utile plus d'une fois à l'avenir. Vous pouvez voir l'application pleinement fonctionnelle de cet article dans le référentiel .

PS: et depuis qu'ils m'ont donné la parole en tant que traducteur, notre équipe Android a une place pour un développeur . Merci de votre attention.

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


All Articles