Les animations dans Flutter sont puissantes et très simples à utiliser. Grâce à un exemple concret, vous apprendrez tout ce dont vous avez besoin pour réaliser vos propres animations.

Difficulté: Intermédiaire

Aujourd’hui, nous ne pouvons pas imaginer une application mobile sans animations. Lorsque vous passez d’une page à l’autre, appuyez sur un bouton (ou InkWell) … il y a une animation. Les animations sont partout.

Flutter rend les animations très faciles à réaliser.

En termes très simples, cet article aborde ce sujet, précédemment réservé aux spécialistes et, pour rendre ce papier attractif, j’ai relevé comme défi d’implémenter dans Flutter, étape par étape, l’effet Menu Guillotine suivant, posté par Vitaly Rubtsov sur Dribble.

original original

La première partie de cet article explique la théorie et les principaux concepts. La deuxième partie est dédiée à la mise en œuvre de l’animation, montrée dans la vidéo ci-dessus.

Les 3 piliers d’une Animation

Pour réaliser une Animation, les 3 éléments suivants doivent être présents:

  • un Ticker
  • une Animation
  • un AnimationController

Voici une introduction rapide à ces éléments. Plus d’explications viendront plus tard.

Le Ticker

En termes simples, un Ticker est une classe qui émet un signal à intervalle quasi régulier (environ 60 fois par seconde). Pensez à votre montre qui émet un tic à chaque seconde.

A chaque tic, le Ticker invoque la ou les méthodes callback en passant la durée écoulée depuis le premier tic de son démarrage.

IMPORTANT

Tous les tickers, même s’ils ont été démarrés à des moments différents, seront toujours et systématiquement synchronisés. Ceci est très utile pour synchroniser les animations

Animation

Une Animation n’est rien d’autre qu’une valeur (d’un type spécifique) qui peut changer au cours de la vie de l’animation. La façon dont la valeur (= value) change au cours de l’animation peut être linéaire (comme 1, 2, 3, 4, 5 …) ou beaucoup plus complexe (voir Curves, plus loin).

AnimationController

La classe AnimationController est la classe qui contrôle (démarrer, arrêter, répéter, …) une animation (ou plusiers animations). En d’autres termes, elle fait varier la valeur de l’animation depuis une valeur de début (= “lowerBound”) vers une valeur finale (= “upperBound”) endéans un certain laps de temps (= “duration”), utilisant une vélocité (= “velocity” ou nombre de changements de la valeur par seconde).


La classe AnimationController

Cette classe donne le contrôle sur une animation. Pour être plus précis, je devrais plutôt dire “sur une scène” car, comme nous le verrons un peu plus loin, plusieurs animations distinctes pourraient être contrôlées par un même contrôleur…

Donc, grâce à cette classe AnimationController, nous pouvons:

  • jouer une scène en avant (= forward), en arrière (= reverse)
  • arrêter une scène
  • positionner une scène à une certaine valeur
  • définir les valeurs limites (lowerBound, upperBound) d’une scène

Le pseudo-code suivant montre les différents paramètres d’initialisation de cette classe:

AnimationController controller = new AnimationController(
	value:		// la valeur actuelle de l'animation, généralement 0.0 (= par défaut)
	lowerBound:	// la valeur minimale de l'animation, généralement 0.0 (= par défaut)
	upperBound:	// la valeur maximale de l'animation, généralement 1.0 (= par défaut)
	duration:	// la durée totale de l'animation entière (scène)
	vsync:		// le ticker
	debugLabel:	// une étiquette à utiliser pour identifier le contrôleur
			// durant une session de débogage
);

La plupart du temps, value, lowerBound, upperBound et debugLabel ne sont pas mentionnés lors de l’initialisation d’un AnimationController.

Comment lier le AnimationController à un Ticker?

Pour fonctionner, un AnimationController doit être lié à un Ticker.

Habituellement, vous allez générer un Ticker, lié à une instance d’un Widget Stateful.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class _MyStateWidget extends State<MyStateWidget>
		with SingleTickerProviderStateMixin {
	AnimationController _controller;

	@override
	void initState(){
	  super.initState();
	  _controller = new AnimationController(
		duration: const Duration(milliseconds: 1000), 
		vsync: this,
	  );
	}

	@override
	void dispose(){
	  _controller.dispose();
	  super.dispose();
	}

	...
}
  • ligne 2

    vous dites à Flutter que vous voulez avoir un nouveau “single” Ticker, lié à cette instance de MyStateWidget

  • lignes 8-10

    initialisation du controller. La durée totale de la scène est de 1000 millisecondes et lié au Ticker (vsync: this).

    Paramètres implicites: lowerBound = 0.0 et upperBound = 1.0

  • ligne 16

    TRES IMPORTANT, vous devez relâcher le controller lorsque l’instance de MyStateWidget est supprimée.

TickerProviderStateMixin ou SingleTickerProviderStateMixin?

Si vous avez plusieurs instances AnimationController et que vous souhaitez avoir des Tickers distincts, remplacez SingleTickerProviderStateMixin par TickerProviderStateMixin.

OK, j’ai un Controller lié à un Ticker… à quoi cela sert-il?

Grâce au Ticker qui émet un signal près de 60 fois par seconde, l’AnimationController produit des valeurs, de manière linéaire, variant de lowerBound to upperBound, sur une certaine durée (= “duration”).

Voici un exemple de valeurs générées durant ce laps de temps de 1000 millisecondes:

Ticker values

Nous voyons que les valeurs varient de 0.0 (lowerBound) à 1.0 (upperBound) en 1000 millisecondes. 51 valeurs différentes ont été générées.

Étendons le code pour voir comment l’utiliser.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class _MyStateWidget extends State<MyStateWidget>
		with SingleTickerProviderStateMixin {
	AnimationController _controller;

	@override
	void initState(){
	  super.initState();
	  _controller = new AnimationController(
		duration: const Duration(milliseconds: 1000), 
		vsync: this,
	  );
	  _controller.addListener((){
		  setState((){});
	  });
	  _controller.forward();
	}

	@override
	void dispose(){
	  _controller.dispose();
	  super.dispose();
	}

	@override
	Widget build(BuildContext context){
		final int percent = (_controller.value * 100.0).round();
		return new Scaffold(
			body: new Container(
				child: new Center(
					child: new Text('$percent%'),
				),
			),
		);
	}
}
  • ligne 12

    cette ligne indique au contrôleur que chaque fois que sa valeur change, nous devons reconstruire le Widget (via setState())

  • ligne 15

    dès que l’initialisation du widget est terminée, nous demandons au contrôleur de commencer à compter (forward() -> du lowerBound au upperBound)

  • ligne 26

    nous récupérons la valeur du contrôleur (_controller.value) et, comme dans cet exemple cette valeur varie de 0.0 à 1.0 (0% à 100%), nous obtenons l’expression entière de ce pourcentage, à afficher au centre de la page.


La notion: Animation

Comme nous venons de le voir, le controller renvoie une série de valeurs décimales qui varient les unes par rapport aux autres de façon linéaire.

Parfois, nous aimerions:

  • utiliser d’autres types de valeurs, tels que Offset, int
  • utiliser une variation de valeurs, autre que de 0.0 à 1.0
  • utiliser d’autres types de variations que linéaire afin d’obtenir des effets

Utilisation d’autres types de valeurs

Afin de permettre l’utilisation d’autres types de valeurs, la classe Animation est une classe template.

En d’autres termes, vous pouvez définir:

Animation<int> integerVariation;
Animation<double> decimalVariation;
Animation<Offset> offsetVariation;

Utilisation de différentes plages de valeurs

Parfois, nous aimerions avoir une variation entre 2 valeurs, différentes de 0.0 et 1.0.

Afin de définir une telle plage, nous utiliserons la classe Tween.

Pour illustrer ceci, considérons le cas où vous voudriez avoir un angle qui varie de 0 à π/2 rad.

Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2);

Types de variations

Comme déjà expliqué plus tôt, la manière par défaut de faire varier une valeur de lowerBound à upperBound est une méthode linéaire.

Si vous voulez que l’angle varie linéairement de 0 à π/2 radians, liez Animation au AnimationController:

Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(_controller);

Lorsque vous lancerez l’animation (via _controller.forward()), angleAnimation.value utilisera le _controller.value pour faire une interpolation sur la plage [0.0; π/2].

Le graphique suivant montre une telle variation linéaire (π/2 = 1.57) Ticker values2

Les variations de type Curved, définies par Flutter

Flutter propose un ensemble de variations prédéfinies. En voici la liste:

decelerate decelerate
ease ease
easeIn easeIn
easeOut easeOut
easeInOut easeInOut
fastOutSlowIn fastOutSlowIn
fastOutSlowIn fastOutSlowIn
bounceIn bounceIn
bounceOut bounceOut
bounceInOut bounceInOut
elasticIn elasticIn
elasticOut elasticOut
elasticInOut elasticInOut
bounceIn bounceIn
bounceOut bounceOut
bounceInOut bounceInOut

Pour utiliser ces variations:

Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(
	new CurvedAnimation(
		parent: _controller,
		curve:  Curves.ease,
		reverseCurve: Curves.easeOut
	));

Ceci crée une variation de valeur [0; π/2], qui évolue en utilisant

  • Curves.ease quand l’animation va de 0.0 -> π/2 (= forward)
  • Curves.easeOut quand l’animation va de π/2 -> 0.0 (= reverse)

Contrôler les animations

Le AnimationController est la classe qui vous permet de prendre le contrôle sur l’animation, via une API. (Voici l’API la plus couramment utilisée):

  • _controller.forward({ double from })

    demande au contrôleur de commencer à faire varier les valeurs de lowerBound -> upperBound

    L’argument optionnel from peut être utilisé pour forcer le controller à commencer à “compter” à partir d’une autre valeur que lowerBound

  • _controller.reverse({ double from })

    demande au contrôleur de commencer à faire varier les valeurs de upperBound -> lowerBound

    L’argument optionnel from peut être utilisé pour forcer le controller à commencer à “compter” à partir d’une autre valeur que upperBound

  • _controller.stop({ bool canceled: true })

    stoppe une animation

  • _controller.reset()

    réinitialise l’animation à lowerBound

  • _controller.animateTo(double target, { Duration duration, Curve curve: Curves.linear })

    fait avancer l’animation de sa valeur actuelle à la valeur target

  • _controller.repeat({ double min, double max, Duration period })

    commence à exécuter l’animation dans le sens avance et redémarre l’animation lorsqu’elle se termine.

    Si définis, min et max limitent le nombre de répétitions.

Soyons prudents…

Comme une animation peut être arrêtée de manière inattendue (par exemple, l’écran est enlevé), lors de l’utilisation de l’une de ces API, il est plus sûr d’ajouter le “.orCancel“:

__controller.forward().orCancel;

Grâce à cette petite astuce, aucune exception ne sera levée si le Ticker est annulé avant que le _controller ne soit détruit.

La notion de Scène

Ce mot “scène” n’existe pas dans la documentation officielle mais personnellement, je le trouve plus proche de la réalité. Laissez-moi m’expliquer.

Comme déjà expliqué, un AnimationController gère/contrôle une Animation. Néanmoins, le mot “Animation” peut également signifier une séries de sous-animations qui doivent être jouées en séquence ou en se chevauchant. La définition de la façon dont nous enchaînons les sous-animations est ce que j’appelle une “scène”.

Considérons le cas suivant où une durée entière d’une animation serait de 10 secondes et que nous aimerions:

  • les 2 premières secondes, une balle se déplace du côté gauche au milieu de l’écran
  • ensuite, la même balle prend 3 secondes pour se déplacer du centre vers le haut de l’écran
  • enfin, la balle prend 5 secondes pour disparaître.

Comme vous l’imaginez probablement, nous devons considérer 3 animations distinctes:

///
/// Définition du _controller avec une durée totale de 10 secondes
///
AnimationController _controller = new AnimationController(
	duration: const Duration(seconds: 10), 
	vsync: this
);

///
/// Première animation qui déplace la balle de la gauche vers le centre
///
Animation<Offset> moveLeftToCenter = new Tween(
	begin: new Offset(0.0, screenHeight /2), 
	end: new Offset(screenWidth /2, screenHeight /2)
).animate(_controller);

///
/// Deuxième animation qui déplace la balle du centre vers le haut
///
Animation<Offset> moveCenterToTop = new Tween(
	begin: new Offset(screenWidth /2, screenHeight /2), 
	end: new Offset(screenWidth /2, 0.0)
).animate(_controller);

///
/// Troisième animation qui servira à changer l'opacité de la balle pour la faire disparaître
///
Animation<double> disappear = new Tween(
	begin: 1.0, 
	end: 0.0
).animate(_controller);

Maintenant, la question, comment pouvons-nous enchaîner (ou orchestrer) les sous-animations?

La notion d’Intervalle (= Interval)

La solution est donnée par l’utilisation de la classe Interval. Mais qu’est-ce qu’un intervalle?

Contrairement à ce que nous pourrions penser directement, un intervalle ne se rapporte PAS à un intervalle de temps mais à un intervalle de valeurs.

Si vous considérez le _controller, vous devez vous rappeler qu’il fait varier une valeur d’un lowerBound à un upperBound.

Habituellement, ces 2 valeurs sont respectivement définies de lowerBound = 0.0 et upperBound = 1.0 ce qui rend les choses beaucoup plus faciles à considérer puisque [0.0 -> 1.0] n’est rien d’autre qu’une variation de 0% à 100%.

Donc, si la durée totale d’une scène est de 10 secondes, il est plus que probable qu’au bout de 5 secondes, le _controller.value correspondant soit très proche de 0.5 (= 50%).

Si on met les 3 animations distinctes sur une ligne du temps, on obtient: timeline

Si nous considérons maintenant les intervalles de valeurs, pour chacune des 3 animations, nous obtenons:

  • moveLeftToCenter

    durée: 2 secondes, démarre à 0 seconde, finit à 2 secondes => intervalle = [0;2] => percentages: de 0% à 20% de la scène entière => [0.0;0.20]

  • moveCenterToTop

    durée: 3 secondes, démarre à 2 secondes, finit à 5 secondes => intervalle = [2;5] => percentages: de 20% à 50% de la scène entière => [0.20; 0.50]

  • disappear

    durée: 5 secondes, démarre à 5 secondes, finit à 10 secondes => intervalle = [5;10] => percentages: de 50% à 100% de la scène entière => [0.50;1.0]

Maintenant que nous avons ces pourcentages, nous pouvons mettre à jour la définition de chaque animation individuelle comme suit:

///
/// Définition du _controller avec une durée totale de 10 secondes
///
AnimationController _controller = new AnimationController(
	duration: const Duration(seconds: 10), 
	vsync: this
);

///
/// Première animation qui déplace la balle de la gauche vers le centre
///
Animation<Offset> moveLeftToCenter = new Tween(
	begin: new Offset(0.0, screenHeight /2), 
	end: new Offset(screenWidth /2, screenHeight /2)
	).animate(
            new CurvedAnimation(
                parent: _controller,
                curve:  new Interval(
                    0.0,
                    0.20,
                    curve: Curves.linear,
                ),
            ),
        );

///
/// Deuxième animation qui déplace la balle du centre vers le haut
///
Animation<Offset> moveCenterToTop = new Tween(
	begin: new Offset(screenWidth /2, screenHeight /2), 
	end: new Offset(screenWidth /2, 0.0)
	).animate(
            new CurvedAnimation(
                parent: _controller,
                curve:  new Interval(
                    0.20,
                    0.50,
                    curve: Curves.linear,
                ),
            ),
        );

///
/// Troisième animation qui servira à changer l'opacité de la balle pour la faire disparaître
///
Animation<double> disappear = new Tween(begin: 1.0, end: 0.0)
        .animate(
            new CurvedAnimation(
                parent: _controller,
                curve:  new Interval(
                    0.50,
                    1.0,
                    curve: Curves.linear,
                ),
            ),
        );

C’est tout ce que vous devez configurer pour définir une scène (ou une série d’animations). Bien sûr, rien ne vous empêche de superposer les sous-animations…

Répondre au status (état) d’une Animation

Parfois, il est pratique de connaître le statut d’une animation (ou d’une scène).

Une animation peut avoir 4 statuts distincts:

  • dismissed: l’animation est arrêtée au début (ou n’a pas encore commencé)
  • forward: l’animation va du début à la fin
  • reverse: l’animation tourne à rebours, de la fin au début
  • completed: l’animation est arrêtée à la fin

Pour obtenir ce statut, nous devons écouter les changements d’état de l’animation, de la façon suivante:

    myAnimation.addStatusListener((AnimationStatus status){
        switch(status){
            case AnimationStatus.dismissed:
                ...
                break;

            case AnimationStatus.forward:
                ...
                break;

            case AnimationStatus.reverse:
                ...
                break;

            case AnimationStatus.completed:
                ...
                break;
        }
    });

Une utilisation typique si ce statut est un rebond. Par exemple, une fois l’animation terminée, nous voulons l’inverser. Pour réaliser ceci:

    myAnimation.addStatusListener((AnimationStatus status){
        switch(status){
            ///
            /// Lorsque l'animation est au début, nous forçons l'animation à jouer
            ///
            case AnimationStatus.dismissed:
                _controller.forward();
                break;

            ///
            /// Lorsque l'animation est à la fin, nous forçons l'animation à s'inverser
            ///
            case AnimationStatus.completed:
                _controller.reverse();
                break;
        }
    });

Assez de théorie. Passons à la pratique !

Maintenant que la théorie a été introduite, il est temps de pratiquer …

Comme je l’ai mentionné au début de cet article, je vais mettre en pratique cette notion d’animation en mettant en œuvre une animation, appelée “guillotine”.

Analyse des animations et du squelette initial

Pour avoir cet effet guillotine, il faut d’abord considérer:

  • le contenu de la page lui-même
  • une barre de menu qui pivote lorsque nous appuyons sur l’icône menu (ou hamburger)
  • lors de la rotation ouverture, le menu chevauche le contenu de la page et remplit toute la fenêtre
  • une fois que le menu est entièrement visible et que nous appuyons à nouveau sur l’icône menu, le menu pivote fermeture pour revenir à sa position et ses dimensions d’origine

De ces observations, nous pouvons immédiatement déduire que nous n’utilisons pas un Scaffold normal avec un AppBar (puisque ce dernier est fixe).

Nous allons plutôt utiliser un Stack de 2 couches:

  • le contenu de la page (couche inférieure)
  • le menu (couche supérieure)

Commençons par construire ce squelette:

class MyPage extends StatefulWidget {
    @override
    _MyPageState createState() => new _MyPageState();
}

class _MyPageState extends State<MyPage>{
  @override
  Widget build(BuildContext context){
      return SafeArea(
        top: false,
        bottom: false,
        child: new Container(
          child: new Stack(
            alignment: Alignment.topLeft,
            children: <Widget>[
              new Page(),
              new GuillotineMenu(),
            ],
          ),
        ),
      );
  }
}

class Page extends StatelessWidget {
    @override
    Widget build(BuildContext context){
        return new Container(
            padding: const EdgeInsets.only(top: 90.0),
            color: Color(0xff222222),
        );
    }
}

class GuillotineMenu extends StatefulWidget {
    @override
    _GuillotineMenuState createState() => new _GuillotineMenuState();
}

class _GuillotineMenuState extends State<GuillotineMenu> {

    @overrride
    Widget build(BuildContext context){
        return new Container(
            color: Color(0xff333333),
        );
    }
}

Le résultat de ce code donne un écran noir, révélant seulement le GuillotineMenu, couvrant toute la fenêtre.

Analyse du menu lui-même

Si vous regardez de plus près la vidéo, vous pouvez voir que lorsque le menu est complètement ouvert, il couvre entièrement la fenêtre d’affichage. Quand il est ouvert, seul quelque chose comme un AppBar est visible.

Rien ne nous empêche de voir les choses différemment … et si le GuillotineMenu était initialement tourné et quand nous appuyons sur le bouton menu, nous le faisions pivoter de π/2, comme le montre l’image suivante?

animation guillotine analysis

Nous pouvons ensuite réécrire la classe _GuillotineMenuState comme suit: (aucune explication n’est donnée sur la façon de construire la mise en page, puisque ce n’est pas l’objectif de cet article)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
class _GuillotineMenuState extends State<GuillotineMenu> {
   double rotationAngle = 0.0;

    @override
    Widget build(BuildContext context){
        MediaQueryData mediaQueryData = MediaQuery.of(context);
		double screenWidth = mediaQueryData.size.width;
		double screenHeight = mediaQueryData.size.height;

		return new Transform.rotate(
				angle: rotationAngle,
				origin: new Offset(24.0, 56.0),
				alignment: Alignment.topLeft,
				child: Material(
					color: Colors.transparent,
					child: Container(
					width: screenWidth,
					height: screenHeight,
					color: Color(0xFF333333),
					child: new Stack(
						children: <Widget>[
							_buildMenuTitle(),
							_buildMenuIcon(),
							_buildMenuContent(),
						],
					),
				),
			),
		);
    }

	///
	/// Menu Title
	///
	Widget _buildMenuTitle(){
		return new Positioned(
			top: 32.0,
			left: 40.0,
			width: screenWidth,
			height: 24.0,
			child: new Transform.rotate(
				alignment: Alignment.topLeft,
				origin: Offset.zero,
				angle: pi / 2.0,
				child: new Center(
				child: new Container(
					width: double.infinity,
					height: double.infinity,
					child: new Opacity(
					opacity: 1.0,
					child: new Text('ACTIVITY',
						textAlign: TextAlign.center,
						style: new TextStyle(
							color: Colors.white,
							fontSize: 20.0,
							fontWeight: FontWeight.bold,
							letterSpacing: 2.0,
						)),
					),
				),
			)),
		);
	}

	///
	/// Menu Icon
	/// 
	Widget _buildMenuIcon(){
		return new Positioned(
			top: 32.0,
			left: 4.0,
			child: new IconButton(
				icon: const Icon(
					Icons.menu,
					color: Colors.white,
				),
				onPressed: (){},
			),
		);
	}

	///
	/// Menu content
	///
	Widget _buildMenuContent(){
		final List<Map> _menus = <Map>[
			{
			"icon": Icons.person,
			"title": "profile",
			"color": Colors.white,
			},
			{
			"icon": Icons.view_agenda,
			"title": "feed",
			"color": Colors.white,
			},
			{
			"icon": Icons.swap_calls,
			"title": "activity",
			"color": Colors.cyan,
			},
			{
			"icon": Icons.settings,
			"title": "settings",
			"color": Colors.white,
			},
		];

		return new Padding(
			padding: const EdgeInsets.only(left: 64.0, top: 96.0),
			child: new Container(
				width: double.infinity,
				height: double.infinity,
				child: new Column(
					mainAxisAlignment: MainAxisAlignment.start,
					children: _menus.map((menuItem) {
						return new ListTile(
							leading: new Icon(
							menuItem["icon"],
							color: menuItem["color"],
							),
							title: new Text(
							menuItem["title"],
							style: new TextStyle(
								color: menuItem["color"],
								fontSize: 24.0),
							),
						);
					}).toList(),
				),
			),
		);
	}
}
  • Lignes 10-13

    ces lignes définissent la rotation du Menu Guillotine, autour d’un centre de rotation (la position de l’icône menu)

Maintenant, le résultat de ce code donne un écran de menu non pivoté (puisque rotationAngle = 0.0); ce qui montre le titre affiché verticalement.

Animons le menu

Si vous mettez à jour la valeur de rotationAngle (entre -π/2 et 0), vous verrez le menu pivoter d’un angle correspondant.

Mettons de l’animation…

Comme expliqué précédemment, nous avons besoin de

  • un SingleTickerProviderStateMixin, puisque nous n’avons qu’une scene
  • un AnimationController
  • une Animation pour obtenir une variation d’angle

Le code devient:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
class _GuillotineMenuState extends State<GuillotineMenu>
	with SingleTickerProviderStateMixin {

    AnimationController animationControllerMenu;
    Animation<double> animationMenu;

	///
	/// Menu Icon, onPress() handling
	///
    _handleMenuOpenClose(){
        animationControllerMenu.forward();
    }

    @override
    void initState(){
        super.initState();

	///
    	/// Initialization of the animation controller
    	///
        animationControllerMenu = new AnimationController(
			duration: const Duration(milliseconds: 1000), 
			vsync: this
		)..addListener((){
            setState((){});
        });

	///
    	/// Initialization of the menu appearance animation
    	///
        _rotationAnimation = new Tween(
			begin: -pi/2.0, 
			end: 0.0
		).animate(animationControllerMenu);
    }

    @override
    void dispose(){
        animationControllerMenu.dispose();
        super.dispose();
    }

    @override
    Widget build(BuildContext context){
        MediaQueryData mediaQueryData = MediaQuery.of(context);
		double screenWidth = mediaQueryData.size.width;
		double screenHeight = mediaQueryData.size.height;
		double angle = animationMenu.value;
		
		return new Transform.rotate(
			angle: angle,
			origin: new Offset(24.0, 56.0),
			alignment: Alignment.topLeft,
			child: Material(
				color: Colors.transparent,
				child: Container(
					width: screenWidth,
					height: screenHeight,
					color: Color(0xFF333333),
					child: new Stack(
						children: <Widget>[
							_buildMenuTitle(),
							_buildMenuIcon(),
							_buildMenuContent(),
						],
					),
				),
			),
		);
    }

	...
	///
	/// Menu Icon
	/// 
	Widget _buildMenuIcon(){
		return new Positioned(
			top: 32.0,
			left: 4.0,
			child: new IconButton(
				icon: const Icon(
					Icons.menu,
					color: Colors.white,
				),
				onPressed: _handleMenuOpenClose,
			),
		);
	}
	...
}

OK, lorsque nous appuyons sur le bouton menu, le menu s’ouvre mais ne se ferme pas lorsque nous appuyons à nouveau sur le bouton.

C’est ici qu’intervient le AnimationStatus.

Ajoutons un listener et, selon AnimationStatus, décidons de jouer l’animation en avant ou en arrière.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
///
/// Menu animation status
///
enum _GuillotineAnimationStatus { closed, open, animating }

class _GuillotineMenuState extends State<GuillotineMenu>
	with SingleTickerProviderStateMixin {
    AnimationController animationControllerMenu;
    Animation<double> animationMenu;
    _GuillotineAnimationStatus menuAnimationStatus = _GuillotineAnimationStatus.closed;

    _handleMenuOpenClose(){
        if (menuAnimationStatus == _GuillotineAnimationStatus.closed){
            animationControllerMenu.forward().orCancel;
        } else if (menuAnimationStatus == _GuillotineAnimationStatus.open) {
            animationControllerMenu.reverse().orCancel;
        }
    }

    @override
    void initState(){
        super.initState();

	///
    	/// Initialisation du contrôleur d'animation
    	///
        animationControllerMenu = new AnimationController(
			duration: const Duration(milliseconds: 1000), 
			vsync: this
		)..addListener((){
            setState((){});
        })..addStatusListener((AnimationStatus status) {
            if (status == AnimationStatus.completed) {
		///
		/// Lorsque l'animation est à la fin, le menu est ouvert
		///
              menuAnimationStatus = _GuillotineAnimationStatus.open;
            } else if (status == AnimationStatus.dismissed) {
		///
		/// Lorsque l'animation est au début, le menu est fermé
		///
              menuAnimationStatus = _GuillotineAnimationStatus.closed;
            } else {
		///
		/// Sinon, l'animation est en cours d'exécution
		///
              menuAnimationStatus = _GuillotineAnimationStatus.animating;
            }
          });

	...
    }
...
}

Le menu est maintenant ouvert ou fermé comme prévu mais la vidéo nous montre un mouvement d’ouverture / fermeture qui n’est pas linéaire mais qui ressemble à un effet rebondissant. Ajoutons cet effet.

Pour cela je vais choisir les 2 effets suivants:

  • bounceOut pour l’ouverture
  • bounceIn pour la fermeture

bounceOut bounceOut
bounceIn bounceIn

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class _GuillotineMenuState extends State<GuillotineMenu>
	with SingleTickerProviderStateMixin {
...
    @override
    void initState(){
	...
	///
	/// Initialisation de l'animation d'ouverture/fermeture
	/// 
	animationMenu = new Tween(
		begin: -pi / 2.0, 
		end: 0.0
	).animate(new CurvedAnimation(
		parent: animationControllerMenu,
		curve: Curves.bounceOut,
		reverseCurve: Curves.bounceIn,
	));
    }
...
}

Il y a encore quelque chose qui manque dans cette implémentation … le fait que le titre disparaisse lors de l’ouverture du menu et réapparaisse en le fermant. C’est un effet d’entrée/sortie (= fade in/out), à traiter comme une animation. Ajoutons-le.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class _GuillotineMenuState extends State<GuillotineMenu>
    with SingleTickerProviderStateMixin {
  AnimationController animationControllerMenu;
  Animation<double> animationMenu;
  Animation<double> animationTitleFadeInOut;
  _GuillotineAnimationStatus menuAnimationStatus;

...
  @override
  void initState(){
	...
    ///
    /// Initialisation de l'animation fade in/out
    /// 
    animationTitleFadeInOut = new Tween(
		begin: 1.0, 
		end: 0.0
	).animate(new CurvedAnimation(
      	parent: animationControllerMenu,
      	curve: new Interval(
        	0.0,
        	0.5,
        	curve: Curves.ease,
      	),
    ));
  }
...
  ///
  /// Menu Title
  ///
  Widget _buildMenuTitle(){
    return new Positioned(
	  top: 32.0,
	  left: 40.0,
	  width: screenWidth,
	  height: 24.0,
	  child: new Transform.rotate(
		alignment: Alignment.topLeft,
		origin: Offset.zero,
		angle: pi / 2.0,
		child: new Center(
	  	  child: new Container(
			width: double.infinity,
			height: double.infinity,
		  	  child: new Opacity(
			    opacity: animationTitleFadeInOut.value,
				child: new Text('ACTIVITY',
					textAlign: TextAlign.center,
					style: new TextStyle(
						color: Colors.white,
						fontSize: 20.0,
						fontWeight: FontWeight.bold,
						letterSpacing: 2.0,
					)),
				),
			),
		)),
	);
  }
...
}

Résultat

Voici le résultat que j’obtiens, qui est très proche de l’original, n’est-ce pas?

résultat résultat

Code Source

Le code source de cet article se trouve sur GitHub.


Conclusions

Comme vous l’avez vu, il est très simple de construire des animations, même complexes.

J’espère que cet article assez long a réussi à démystifier la notion des Animations en Flutter.

Restez à l’écoute pour les prochains articles et bon codage.