Animations dans Flutter

1. Introduction

Les animations sont un excellent moyen d'améliorer l'expérience utilisateur de votre application, de lui communiquer des informations importantes et de la rendre plus soignée et agréable à utiliser.

Présentation du framework d'animation de Flutter

Flutter affiche des effets d'animation en reconstruisant une partie de l'arborescence des widgets à chaque frame. Il fournit des effets d'animation prédéfinis et d'autres API pour faciliter la création et la composition d'animations.

  • Les animations implicites sont des effets d'animation prédéfinis qui exécutent automatiquement l'ensemble de l'animation. Lorsque la valeur cible de l'animation change, l'animation s'exécute de la valeur actuelle à la valeur cible, et affiche chaque valeur entre les deux afin que le widget s'anime de manière fluide. AnimatedSize, AnimatedScale et AnimatedPositioned sont des exemples d'animations implicites.
  • Les animations explicites sont également des effets d'animation prédéfinis, mais elles nécessitent un objet Animation pour fonctionner. Par exemple, SizeTransition, ScaleTransition ou PositionedTransition.
  • Animation est une classe qui représente une animation en cours d'exécution ou arrêtée. Elle se compose d'une valeur représentant la valeur cible vers laquelle l'animation s'exécute et de l'état, qui représente la valeur actuelle que l'animation affiche à l'écran à un moment donné. Il s'agit d'une sous-classe de Listenable et il avertit ses écouteurs lorsque l'état change pendant l'exécution de l'animation.
  • AnimationController permet de créer une animation et de contrôler son état. Ses méthodes telles que forward(), reset(), stop() et repeat() peuvent être utilisées pour contrôler l'animation sans avoir à définir l'effet d'animation affiché, comme l'échelle, la taille ou la position.
  • Les animations de transition permettent d'interpoler des valeurs entre une valeur de début et une valeur de fin. Elles peuvent représenter n'importe quel type, comme un double, Offset ou Color.
  • Les courbes permettent d'ajuster le taux de variation d'un paramètre au fil du temps. Lorsqu'une animation s'exécute, il est courant d'appliquer une courbe d'accélération pour accélérer ou ralentir la fréquence de changement au début ou à la fin de l'animation. Les courbes acceptent une valeur d'entrée comprise entre 0,0 et 1,0 et renvoient une valeur de sortie comprise entre 0,0 et 1,0.

Objectifs de l'atelier

Dans cet atelier de programmation, vous allez créer un jeu de quiz à choix multiples qui présente différents effets et techniques d'animation.

3026390ad413769c.gif

Vous allez découvrir comment :

  • Créer un widget qui anime sa taille et sa couleur
  • Créer un effet de retournement de carte en 3D
  • Utiliser des effets d'animation prédéfinis sophistiqués du package d'animations
  • Ajouter la prise en charge de la prévisualisation du geste Retour disponible sur la dernière version d'Android

Points abordés

Dans cet atelier de programmation, vous allez apprendre:

  • Utiliser des effets d'animation implicites pour créer des animations de qualité sans avoir à écrire beaucoup de code
  • Utiliser des effets animés explicites pour configurer vos propres effets à l'aide de widgets animés prédéfinis tels que AnimatedSwitcher ou AnimationController.
  • Utiliser AnimationController pour définir votre propre widget qui affiche un effet 3D
  • Utiliser le package animations pour afficher des effets d'animation sophistiqués avec une configuration minimale

Prérequis

  • Le SDK Flutter
  • Un IDE, tel que VSCode ou Android Studio / IntelliJ

2. Configurer votre environnement de développement Flutter

Pour cet atelier, vous avez besoin de deux logiciels : le SDK Flutter et un éditeur.

Vous pouvez exécuter l'atelier de programmation sur l'un des appareils suivants :

  • Un appareil Android (recommandé pour implémenter la prévisualisation du Retour à l'étape 7) ou iOS physique connecté à votre ordinateur et réglé en mode développeur
  • Le simulateur iOS (les outils Xcode doivent être installés)
  • Android Emulator (à configurer dans Android Studio)
  • Un navigateur (Chrome est requis pour le débogage)
  • Un ordinateur de bureau Windows, Linux ou macOS Vous devez développer votre application sur la plate-forme où vous comptez la déployer. Par exemple, si vous voulez développer une application de bureau Windows, vous devez le faire sous Windows pour accéder à la chaîne de compilation appropriée. Les exigences spécifiques aux systèmes d'exploitation sont détaillées sur docs.flutter.dev/desktop.

Vérifier votre installation

Pour vérifier que votre SDK Flutter est correctement configuré et que vous avez installé au moins l'une des plates-formes cibles ci-dessus, utilisez l'outil Flutter Doctor:

$ flutter doctor

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.24.2, on macOS 14.6.1 23G93 darwin-arm64, locale
    en)
[✓] Android toolchain - develop for Android devices
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio
[✓] IntelliJ IDEA Ultimate Edition
[✓] VS Code
[✓] Connected device (4 available)
[✓] Network resources

• No issues found!

3. Exécuter l'application de démarrage

Télécharger l'application de démarrage

Utilisez git pour cloner l'application de démarrage à partir du dépôt flutter/samples sur GitHub.

$ git clone https://github.com/flutter/codelabs.git
$ cd codelabs/animations/step_01/

Vous pouvez également télécharger le code source au format ZIP.

Exécuter l'application

Pour exécuter l'application, utilisez la commande flutter run et spécifiez un appareil cible, tel que android, ios ou chrome. Pour obtenir la liste complète des plates-formes compatibles, consultez la page Plates-formes compatibles.

$ flutter run -d android

Vous pouvez également exécuter et déboguer l'application à l'aide de l'IDE de votre choix. Pour en savoir plus, consultez la documentation officielle de Flutter.

À la découverte du code

L'application de démarrage est un jeu de quiz à choix multiples qui se compose de deux écrans suivant le modèle de conception MVVM (Model-View-View-Model). QuestionScreen (vue) utilise la classe QuizViewModel (view-model) pour poser à l'utilisateur des questions à choix multiples à partir de la classe QuestionBank (modèle).

  • home_screen.dart : affiche un écran avec un bouton Nouveau jeu
  • main.dart : configure MaterialApp pour utiliser Material 3 et afficher l'écran d'accueil
  • model.dart : définit les classes principales utilisées dans l'application
  • question_screen.dart : affiche l'UI du jeu-questionnaire
  • view_model.dart : stocke l'état et la logique du jeu de quiz, affichés par QuestionScreen.

fbb1e1f7b6c91e21.png

L'application n'est pas encore compatible avec les effets animés, à l'exception de la transition de vue par défaut affichée par la classe Navigator de Flutter lorsque l'utilisateur appuie sur le bouton Nouveau jeu.

4. Utiliser des effets d'animation implicites

Les animations implicites sont un excellent choix dans de nombreuses situations, car elles ne nécessitent aucune configuration spéciale. Dans cette section, vous allez mettre à jour le widget StatusBar pour qu'il affiche un tableau de scores animé. Pour trouver des effets d'animation implicites courants, consultez la documentation de l'API ImplicitlyAnimatedWidget.

206dd8d9c1fae95.gif

Créer le widget de tableau de scores non animé

Créez un fichier lib/scoreboard.dart avec le code suivant:

lib/scoreboard.dart

import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            Icon(
              Icons.star,
              size: 50,
              color:
                  score < i + 1 ? Colors.grey.shade400 : Colors.yellow.shade700,
            )
        ],
      ),
    );
  }
}

Ajoutez ensuite le widget Scoreboard dans les enfants du widget StatusBar, en remplaçant les widgets Text qui affichaient auparavant le score et le nombre total de questions. Votre éditeur doit ajouter automatiquement les import "scoreboard.dart" requises en haut du fichier.

lib/question_screen.dart

class StatusBar extends StatelessWidget {
  final QuizViewModel viewModel;

  const StatusBar({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      child: Padding(
        padding: EdgeInsets.all(8.0),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            Scoreboard(                                        // NEW
              score: viewModel.score,                          // NEW
              totalQuestions: viewModel.totalQuestions,        // NEW
            ),
          ],
        ),
      ),
    );
  }
}

Ce widget affiche une icône en forme d'étoile pour chaque question. Lorsqu'une question est répondue correctement, une autre étoile s'allume instantanément, sans animation. Dans les étapes suivantes, vous allez indiquer à l'utilisateur que son score a changé en animant sa taille et sa couleur.

Utiliser un effet d'animation implicite

Créez un widget appelé AnimatedStar qui utilise un widget AnimatedScale pour modifier la valeur scale de 0.5 à 1.0 lorsque l'étoile devient active:

lib/scoreboard.dart

​​import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            AnimatedStar(                                      // NEW
              isActive: score > i,                             // NEW
            )                                                  // NEW
        ],
      ),
    );
  }
}

class AnimatedStar extends StatelessWidget {                   // Add from here...
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: Icon(
        Icons.star,
        size: 50,
        color: isActive ? _activatedColor : _deactivatedColor,
      ),
    );
  }
}                                                              // To here.

Désormais, lorsque l'utilisateur répond correctement à une question, le widget AnimatedStar met à jour sa taille à l'aide d'une animation implicite. Le color de Icon n'est pas animé ici, mais uniquement le scale, ce qui est effectué par le widget AnimatedScale.

84aec4776e70b870.gif

Utiliser un Tween pour interpoler entre deux valeurs

Notez que la couleur du widget AnimatedStar change immédiatement après que le champ isActive est défini sur "true".

Pour obtenir un effet de couleur animé, vous pouvez essayer d'utiliser un widget AnimatedContainer (qui est une autre sous-classe de ImplicitlyAnimatedWidget), car il peut animer automatiquement tous ses attributs, y compris la couleur. Malheureusement, notre widget doit afficher une icône, et non un conteneur.

Vous pouvez également essayer AnimatedIcon, qui implémente des effets de transition entre les formes des icônes. Toutefois, il n'existe pas d'implémentation par défaut d'une icône en forme d'étoile dans la classe AnimatedIcons.

Nous allons plutôt utiliser une autre sous-classe de ImplicitlyAnimatedWidget appelée TweenAnimationBuilder, qui utilise un Tween comme paramètre. Un tween est une classe qui prend deux valeurs (begin et end) et calcule les valeurs intermédiaires afin qu'une animation puisse les afficher. Dans cet exemple, nous allons utiliser un ColorTween, qui répond à l'interface Tween<Color> requise pour créer notre effet d'animation.

Sélectionnez le widget Icon et utilisez l'action rapide "Encapsuler avec un générateur" dans votre IDE. Remplacez le nom par TweenAnimationBuilder. Indiquez ensuite la durée et ColorTween.

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: TweenAnimationBuilder(                            // Add from here...
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {                     // To here.
          return Icon(
            Icons.star,
            size: 50,
            color: value,                                      // Modify from here...
          );
        },                                                     // To here.
      ),
    );
  }
}

Effectuez maintenant un hot reload de l'application pour voir la nouvelle animation.

8b0911f4af299a60.gif

Notez que la valeur end de notre ColorTween change en fonction de la valeur du paramètre isActive. En effet, TweenAnimationBuilder réexécute son animation chaque fois que la valeur Tween.end change. Dans ce cas, la nouvelle animation s'exécute de la valeur d'animation actuelle à la nouvelle valeur de fin, ce qui vous permet de modifier la couleur à tout moment (même pendant l'exécution de l'animation) et d'afficher un effet d'animation fluide avec les valeurs intermédiaires correctes.

Appliquer une courbe

Ces deux effets d'animation s'exécutent à une vitesse constante, mais les animations sont souvent plus intéressantes et informatives visuellement lorsqu'elles s'accélèrent ou se ralentissent.

Un Curve applique une fonction d'atténuation, qui définit le taux de variation d'un paramètre au fil du temps. Flutter est fourni avec une collection de courbes d'atténuation prédéfinies dans la classe Curves, telles que easeIn ou easeOut.

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

Ces diagrammes (disponibles sur la page de documentation de l'API Curves) donnent un indice sur le fonctionnement des courbes. Les courbes convertissent une valeur d'entrée comprise entre 0,0 et 1,0 (affichée sur l'axe X) en une valeur de sortie comprise entre 0,0 et 1,0 (affichée sur l'axe Y). Ces diagrammes montrent également un aperçu de l'apparence des différents effets d'animation lorsqu'ils utilisent une courbe d'atténuation.

Créez un champ nommé _curve dans AnimatedStar et transmettez-le en tant que paramètre aux widgets AnimatedScale et TweenAnimationBuilder.

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;
  final Curve _curve = Curves.elasticOut;                       // NEW

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      curve: _curve,                                           // NEW
      duration: _duration,
      child: TweenAnimationBuilder(
        curve: _curve,                                         // NEW
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {
          return Icon(
            Icons.star,
            size: 50,
            color: value,
          );
        },
      ),
    );
  }
}

Dans cet exemple, la courbe elasticOut fournit un effet de rétroaction exagéré qui commence par un mouvement de rétroaction et s'équilibre vers la fin.

8f84142bff312373.gif

Effectuez un hot reload de l'application pour voir cette courbe appliquée à AnimatedSize et TweenAnimationBuilder.

206dd8d9c1fae95.gif

Activer les animations lentes à l'aide des outils pour les développeurs

Pour déboguer un effet d'animation, les outils de développement Flutter permettent de ralentir toutes les animations de votre application afin que vous puissiez les voir plus clairement.

Pour ouvrir DevTools, assurez-vous que l'application s'exécute en mode débogage, puis ouvrez l'inspecteur de widgets en le sélectionnant dans la barre d'outils de débogage de VSCode ou en sélectionnant le bouton Open Flutter DevTools (Ouvrir DevTools Flutter) dans la fenêtre d'outil de débogage d'IntelliJ / Android Studio.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

Une fois l'inspecteur de widget ouvert, cliquez sur le bouton Animations lentes dans la barre d'outils.

adea0a16d01127ad.png

5. Utiliser des effets d'animation explicites

Comme les animations implicites, les animations explicites sont des effets d'animation prédéfinis, mais au lieu d'utiliser une valeur cible, elles utilisent un objet Animation comme paramètre. Ils sont donc utiles dans les situations où l'animation est déjà définie par une transition de navigation, AnimatedSwitcher ou AnimationController, par exemple.

Utiliser un effet d'animation explicite

Pour commencer à utiliser un effet d'animation explicite, encapsulez le widget Card avec un AnimatedSwitcher.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(                                 // NEW
      duration: const Duration(milliseconds: 300),           // NEW
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),                                                     // NEW
    );
  }
}

AnimatedSwitcher utilise un effet de fondu par défaut, mais vous pouvez le remplacer à l'aide du paramètre transitionBuilder. Le compilateur de transition fournit le widget enfant qui a été transmis à AnimatedSwitcher et un objet Animation. C'est une excellente occasion d'utiliser une animation explicite.

Pour cet atelier de programmation, la première animation explicite que nous utiliserons est SlideTransition, qui prend un Animation<Offset> qui définit le décalage de début et de fin entre lequel les widgets entrants et sortants se déplaceront.

Les tweens disposent d'une fonction d'assistance, animate(), qui convertit n'importe quel Animation en un autre Animation avec le tween appliqué. Cela signifie qu'un Tween<Offset> peut être utilisé pour convertir le Animation<double> fourni par le AnimatedSwitcher en Animation<Offset>, à fournir au widget SlideTransition.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      transitionBuilder: (child, animation) {               // Add from here...
        final curveAnimation =
            CurveTween(curve: Curves.easeInCubic).animate(animation);
        final offsetAnimation =
            Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
                .animate(curveAnimation);
        return SlideTransition(position: offsetAnimation, child: child);
      },                                                    // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

Notez que Tween.animate est utilisé pour appliquer un Curve au Animation, puis pour le convertir d'un Tween<double> compris entre 0,0 et 1,0 en Tween<Offset> qui passe de -0,1 à 0,0 sur l'axe X.

La classe Animation comporte également une fonction drive() qui prend n'importe quel Tween (ou Animatable) et le convertit en nouveau Animation. Cela permet de "enchaîner" les tweens, ce qui rend le code obtenu plus concis:

lib/question_screen.dart

transitionBuilder: (child, animation) {
  var offsetAnimation = animation
      .drive(CurveTween(curve: Curves.easeInCubic))
      .drive(Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero));
  return SlideTransition(position: offsetAnimation, child: child);
},

Un autre avantage de l'utilisation d'animations explicites est qu'elles peuvent être facilement composées ensemble. Ajoutez une autre animation explicite, FadeTransition, qui utilise la même animation incurvée en encapsulant le widget SlideTransition.

lib/question_screen.dart

return AnimatedSwitcher(
  transitionBuilder: (child, animation) {
    final curveAnimation =
        CurveTween(curve: Curves.easeInCubic).animate(animation);
    final offsetAnimation =
        Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
            .animate(curveAnimation);
    final fadeInAnimation = curveAnimation;                            // NEW
    return FadeTransition(                                             // NEW
      opacity: fadeInAnimation,                                        // NEW
      child: SlideTransition(position: offsetAnimation, child: child), // NEW
    );                                                                 // NEW
  },

Personnaliser layoutBuilder

Vous remarquerez peut-être un petit problème avec AnimationSwitcher. Lorsqu'une fiche de question passe à une nouvelle question, elle s'affiche au centre de l'espace disponible pendant l'animation, mais lorsque l'animation est arrêtée, le widget se fixe en haut de l'écran. Cela entraîne une animation saccadée, car la position finale de la fiche de question ne correspond pas à celle pendant l'exécution de l'animation.

d77de181bdde58f7.gif

Pour résoudre ce problème, AnimatedSwitcher dispose également d'un paramètre layoutBuilder, qui permet de définir la mise en page. Utilisez cette fonction pour configurer l'outil de création de mise en page afin d'aligner la fiche en haut de l'écran:

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return AnimatedSwitcher(
    layoutBuilder: (currentChild, previousChildren) {
      return Stack(
        alignment: Alignment.topCenter,
        children: <Widget>[
          ...previousChildren,
          if (currentChild != null) currentChild,
        ],
      );
    },

Ce code est une version modifiée du defaultLayoutBuilder de la classe AnimatedSwitcher, mais utilise Alignment.topCenter au lieu de Alignment.center.

Résumé

  • Les animations explicites sont des effets d'animation qui utilisent un objet Animation (contrairement aux ImplicitlyAnimatedWidgets, qui utilisent une valeur cible et une durée).
  • La classe Animation représente une animation en cours d'exécution, mais ne définit pas d'effet spécifique.
  • Utilisez Tween().animate ou Animation.drive() pour appliquer des Tweens et des courbes (à l'aide de CurveTween) à une animation.
  • Utilisez le paramètre layoutBuilder d'AnimatedSwitcher pour ajuster la mise en page de ses enfants.

6. Contrôler l'état d'une animation

Jusqu'à présent, chaque animation a été exécutée automatiquement par le framework. Les animations implicites s'exécutent automatiquement, et les effets d'animation explicites nécessitent une animation pour fonctionner correctement. Dans cette section, vous allez apprendre à créer vos propres objets Animation à l'aide d'un AnimationController et à utiliser une TweenSequence pour combiner des Tweens.

Exécuter une animation à l'aide d'un AnimationController

Pour créer une animation à l'aide d'un AnimationController, procédez comme suit:

  1. Créer un StatefulWidget
  2. Utilisez le mixin SingleTickerProviderStateMixin dans votre classe State pour fournir un Ticker à votre AnimationController.
  3. Initialisez l'AnimationController dans la méthode de cycle de vie initState, en fournissant l'objet State actuel au paramètre vsync (TickerProvider).
  4. Assurez-vous que votre widget est recompilé chaque fois que l'AnimationController avertit ses écouteurs, soit à l'aide d'AnimatedBuilder, soit en appelant listen() et setState manuellement.

Créez un fichier nommé flip_effect.dart et copiez-collez le code suivant:

lib/flip_effect.dart

import 'dart:math' as math;

import 'package:flutter/widgets.dart';

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;

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

    _animationController =
        AnimationController(vsync: this, duration: widget.duration);

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });
  }

  @override
  void didUpdateWidget(covariant CardFlipEffect oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (widget.child.key != oldWidget.child.key) {
      _handleChildChanged(widget.child, oldWidget.child);
    }
  }

  void _handleChildChanged(Widget newChild, Widget previousChild) {
    _previousChild = previousChild;
    _animationController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, child) {
        return Transform(
          alignment: Alignment.center,
          transform: Matrix4.identity()
            ..rotateX(_animationController.value * math.pi),
          child: _animationController.isAnimating
              ? _animationController.value < 0.5
                  ? _previousChild
                  : Transform.flip(flipY: true, child: child)
              : child,
        );
      },
      child: widget.child,
    );
  }
}

Cette classe configure un AnimationController et réexécute l'animation chaque fois que le framework appelle didUpdateWidget pour l'informer que la configuration du widget a changé et qu'il peut y avoir un nouveau widget enfant.

AnimatedBuilder s'assure que l'arborescence de widgets est recréée chaque fois que l'AnimationController informe ses écouteurs. Le widget Transform permet d'appliquer un effet de rotation 3D pour simuler le retournement d'une carte.

Pour utiliser ce widget, encapsulez chaque fiche de réponse dans un widget CardFlipEffect. Assurez-vous de fournir un key au widget de carte:

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(                                    // NEW
        duration: const Duration(milliseconds: 300),            // NEW
        child: Card.filled(                                     // NEW
          key: ValueKey(answers[index]),                        // NEW
          color: color,
          elevation: 2,
          margin: EdgeInsets.all(8),
          clipBehavior: Clip.hardEdge,
          child: InkWell(
            onTap: () => onTapped(index),
            child: Padding(
              padding: EdgeInsets.all(16.0),
              child: Center(
                child: Text(
                  answers.length > index ? answers[index] : '',
                  style: Theme.of(context).textTheme.titleMedium,
                  overflow: TextOverflow.clip,
                ),
              ),
            ),
          ),
        ),                                                      // NEW
      );
    }),
  );
}

Effectuez maintenant une actualisation à chaud de l'application pour voir les fiches de réponse se retourner à l'aide du widget CardFlipEffect.

5455def725b866f6.gif

Vous remarquerez peut-être que cette classe ressemble beaucoup à un effet d'animation explicite. En fait, il est souvent judicieux d'étendre directement la classe AnimatedWidget pour implémenter votre propre version. Malheureusement, comme cette classe doit stocker le widget précédent dans son état, elle doit utiliser un StatefulWidget. Pour en savoir plus sur la création de vos propres effets d'animation explicites, consultez la documentation de l'API AnimatedWidget.

Ajouter un délai à l'aide de TweenSequence

Dans cette section, vous allez ajouter un délai au widget CardFlipEffect afin que chaque carte se retourne une à une. Pour commencer, ajoutez un champ appelé delayAmount.

lib/flip_effect.dart

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;
  final double delayAmount;                      // NEW

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
    required this.delayAmount,                   // NEW
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

Ajoutez ensuite le delayAmount à la méthode de compilation AnswerCards.

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(
        delayAmount: index.toDouble() / 2,                     // NEW
        duration: const Duration(milliseconds: 300),
        child: Card.filled(
          key: ValueKey(answers[index]),

Ensuite, dans _CardFlipEffectState, créez une animation qui applique le délai à l'aide d'un TweenSequence. Notez que cette méthode n'utilise pas d'utilitaires de la bibliothèque dart:async, comme Future.delayed. En effet, le délai fait partie de l'animation et n'est pas contrôlé explicitement par le widget lorsqu'il utilise AnimationController. Cela facilite le débogage de l'effet d'animation lorsque vous activez les animations lentes dans DevTools, car il utilise le même TickerProvider.

Pour utiliser un TweenSequence, créez deux TweenSequenceItem, l'un contenant un ConstantTween qui maintient l'animation à 0 pendant une durée relative et un Tween standard qui va de 0.0 à 1.0.

lib/flip_effect.dart

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;
  late final Animation<double> _animationWithDelay; // NEW

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

    _animationController = AnimationController(
        vsync: this, duration: widget.duration * (widget.delayAmount + 1));

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });

    _animationWithDelay = TweenSequence<double>([   // NEW
      if (widget.delayAmount > 0)                   // NEW
        TweenSequenceItem(                          // NEW
          tween: ConstantTween<double>(0.0),        // NEW
          weight: widget.delayAmount,               // NEW
        ),                                          // NEW
      TweenSequenceItem(                            // NEW
        tween: Tween(begin: 0.0, end: 1.0),         // NEW
        weight: 1.0,                                // NEW
      ),                                            // NEW
    ]).animate(_animationController);               // NEW
  }

Enfin, remplacez l'animation de l'AnimationController par la nouvelle animation retardée dans la méthode de compilation.

lib/flip_effect.dart

@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _animationWithDelay,                            // Modify this line
    builder: (context, child) {
      return Transform(
        alignment: Alignment.center,
        transform: Matrix4.identity()
          ..rotateX(_animationWithDelay.value * math.pi),      // And this line
        child: _animationController.isAnimating
            ? _animationWithDelay.value < 0.5                  // And this one.
                ? _previousChild
                : Transform.flip(flipY: true, child: child)
            : child,
      );
    },
    child: widget.child,
  );
}

Effectuez maintenant un hot reload de l'application et regardez les cartes se retourner une par une. Pour vous lancer un défi, essayez de modifier la perspective de l'effet 3D fourni par le widget Transform.

28b5291de9b3f55f.gif

7. Utiliser des transitions de navigation personnalisées

Jusqu'à présent, nous avons vu comment personnaliser des effets sur un seul écran. Mais vous pouvez également utiliser des animations pour effectuer des transitions entre les écrans. Dans cette section, vous allez apprendre à appliquer des effets d'animation aux transitions d'écran à l'aide d'effets d'animation intégrés et d'effets d'animation prédéfinis sophistiqués fournis par le package officiel animations sur pub.dev.

Animer une transition de navigation

La classe PageRouteBuilder est un Route qui vous permet de personnaliser l'animation de transition. Il vous permet de remplacer son rappel transitionBuilder, qui fournit deux objets Animation, représentant l'animation entrante et sortante exécutée par le navigateur.

Pour personnaliser l'animation de transition, remplacez MaterialPageRoute par PageRouteBuilder et pour personnaliser l'animation de transition lorsque l'utilisateur passe de HomeScreen à QuestionScreen. Utilisez un FadeTransition (un widget animé explicitement) pour faire apparaître le nouvel écran par-dessus l'écran précédent.

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(                                         // NEW
        pageBuilder: (context, animation, secondaryAnimation) { // NEW
          return QuestionScreen();                              // NEW
        },                                                      // NEW
        transitionsBuilder:                                     // NEW
            (context, animation, secondaryAnimation, child) {   // NEW
          return FadeTransition(                                // NEW
            opacity: animation,                                 // NEW
            child: child,                                       // NEW
          );                                                    // NEW
        },                                                      // NEW
      ),                                                        // NEW
    );
  },
  child: Text('New Game'),
),

Le package d'animations fournit des effets d'animation prédéfinis sophistiqués, comme FadeThroughTransition. Importez le package d'animations et remplacez FadeTransition par le widget FadeThroughTransition:

lib/home_screen.dart

import 'package;animations/animations.dart';

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
          return FadeThroughTransition(                          // NEW
            animation: animation,                                // NEW
            secondaryAnimation: secondaryAnimation,              // NEW
            child: child,                                        // NEW
          );                                                     // NEW
        },
      ),
    );
  },
  child: Text('New Game'),
),

Personnaliser l'animation de la prévisualisation du Retour

1c0558ffa3b76439.gif

La prévisualisation du Retour est une nouvelle fonctionnalité Android qui permet à l'utilisateur de voir ce qui se cache derrière l'itinéraire ou l'application en cours avant de naviguer. L'animation d'aperçu est déterminée par l'emplacement du doigt de l'utilisateur lorsqu'il le fait glisser vers l'arrière sur l'écran.

Flutter est compatible avec la prévisualisation du Retour du système en activant la fonctionnalité au niveau du système lorsque Flutter n'a aucune route à afficher dans sa pile de navigation, ou en d'autres termes, lorsqu'un Retour quitterait l'application. Cette animation est gérée par le système et non par Flutter lui-même.

Flutter est également compatible avec la prévisualisation du Retour lorsque vous naviguez entre les routes d'une application Flutter. Un PageTransitionsBuilder spécial appelé PredictiveBackPageTransitionsBuilder écoute les gestes de prévisualisation du Retour du système et gère sa transition de page en fonction de la progression du geste.

La prévisualisation du Retour n'est compatible qu'avec Android U ou version ultérieure, mais Flutter revient automatiquement au comportement d'origine du geste Retour et à ZoomPageTransitionBuilder. Pour en savoir plus, consultez notre article de blog, qui comprend une section sur la configuration de cette fonctionnalité dans votre propre application.

Dans la configuration ThemeData de votre application, configurez PageTransitionsTheme pour utiliser PredictiveBack sur Android et l'effet de transition de fondu du package d'animations sur d'autres plates-formes:

lib/main.dart

import 'package:animations/animations.dart';                                 // NEW
import 'package:flutter/material.dart';

import 'home_screen.dart';

void main() {
  runApp(MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
        pageTransitionsTheme: PageTransitionsTheme(
          builders: {
            TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),  // NEW
            TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(),         // NEW
            TargetPlatform.macOS: FadeThroughPageTransitionsBuilder(),       // NEW
            TargetPlatform.windows: FadeThroughPageTransitionsBuilder(),     // NEW
            TargetPlatform.linux: FadeThroughPageTransitionsBuilder(),       // NEW
          },
        ),
      ),
      home: HomeScreen(),
    );
  }
}

Vous pouvez maintenant remplacer l'appel Navigator.push() par un MaterialPageRoute.

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) {       // NEW
        return const QuestionScreen();             // NEW
      }),                                          // NEW
    );
  },
  child: Text('New Game'),
),

Utiliser FadeThroughTransition pour modifier la question actuelle

Le widget AnimatedSwitcher ne fournit qu'une seule animation dans son rappel du générateur. Pour y remédier, le package animations fournit un PageTransitionSwitcher.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(                                          // NEW
      layoutBuilder: (entries) {                                            // NEW
        return Stack(                                                       // NEW
          alignment: Alignment.topCenter,                                   // NEW
          children: entries,                                                // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      transitionBuilder: (child, animation, secondaryAnimation) {           // NEW
        return FadeThroughTransition(                                       // NEW
          animation: animation,                                             // NEW
          secondaryAnimation: secondaryAnimation,                           // NEW
          child: child,                                                     // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

Utiliser OpenContainer

77358e5776eb104c.png

Le widget OpenContainer du package animations fournit un effet d'animation de transformation de conteneur qui se développe pour créer une connexion visuelle entre deux widgets.

Le widget renvoyé par closedBuilder s'affiche initialement et se développe pour afficher le widget renvoyé par openBuilder lorsque l'utilisateur appuie sur le conteneur ou lorsque le rappel openContainer est appelé.

Pour connecter le rappel openContainer au modèle de vue, ajoutez un nouveau pass dans le widget QuestionCard et stockez un rappel qui sera utilisé pour afficher l'écran "Game Over" :

lib/question_screen.dart

class QuestionScreen extends StatefulWidget {
  const QuestionScreen({super.key});

  @override
  State<QuestionScreen> createState() => _QuestionScreenState();
}

class _QuestionScreenState extends State<QuestionScreen> {
  late final QuizViewModel viewModel =
      QuizViewModel(onGameOver: _handleGameOver);
  VoidCallback? _showGameOverScreen;                                    // NEW

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, child) {
        return Scaffold(
          appBar: AppBar(
            actions: [
              TextButton(
                onPressed:
                    viewModel.hasNextQuestion && viewModel.didAnswerQuestion
                        ? () {
                            viewModel.getNextQuestion();
                          }
                        : null,
                child: const Text('Next'),
              )
            ],
          ),
          body: Center(
            child: Column(
              children: [
                QuestionCard(                                           // NEW
                  onChangeOpenContainer: _handleChangeOpenContainer,    // NEW
                  question: viewModel.currentQuestion?.question,        // NEW
                  viewModel: viewModel,                                 // NEW
                ),                                                      // NEW
                Spacer(),
                AnswerCards(
                  onTapped: (index) {
                    viewModel.checkAnswer(index);
                  },
                  answers: viewModel.currentQuestion?.possibleAnswers ?? [],
                  correctAnswer: viewModel.didAnswerQuestion
                      ? viewModel.currentQuestion?.correctAnswer
                      : null,
                ),
                StatusBar(viewModel: viewModel),
              ],
            ),
          ),
        );
      },
    );
  }

  void _handleChangeOpenContainer(VoidCallback openContainer) {        // NEW
    _showGameOverScreen = openContainer;                               // NEW
  }                                                                    // NEW

  void _handleGameOver() {                                             // NEW
    if (_showGameOverScreen != null) {                                 // NEW
      _showGameOverScreen!();                                          // NEW
    }                                                                  // NEW
  }                                                                    // NEW
}

Ajoutez un widget, GameOverScreen:

lib/question_screen.dart

class GameOverScreen extends StatelessWidget {
  final QuizViewModel viewModel;
  const GameOverScreen({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: false,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Scoreboard(
              score: viewModel.score,
              totalQuestions: viewModel.totalQuestions,
            ),
            Text(
              'You Win!',
              style: Theme.of(context).textTheme.displayLarge,
            ),
            Text(
              'Score: ${viewModel.score} / ${viewModel.totalQuestions}',
              style: Theme.of(context).textTheme.displaySmall,
            ),
            ElevatedButton(
              child: Text('OK'),
              onPressed: () {
                Navigator.popUntil(context, (route) => route.isFirst);
              },
            ),
          ],
        ),
      ),
    );
  }
}

Dans le widget QuestionCard, remplacez la carte par un widget OpenContainer du package d'animations, en ajoutant deux nouveaux champs pour le ViewModel et le rappel du conteneur ouvert:

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.onChangeOpenContainer,
    required this.question,
    required this.viewModel,
    super.key,
  });

  final ValueChanged<VoidCallback> onChangeOpenContainer;
  final QuizViewModel viewModel;

  static const _backgroundColor = Color(0xfff2f3fa);

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(
      duration: const Duration(milliseconds: 200),
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },
      child: OpenContainer(                                         // NEW
        key: ValueKey(question),                                    // NEW
        tappable: false,                                            // NEW
        closedColor: _backgroundColor,                              // NEW
        closedShape: const RoundedRectangleBorder(                  // NEW
          borderRadius: BorderRadius.all(Radius.circular(12.0)),    // NEW
        ),                                                          // NEW
        closedElevation: 4,                                         // NEW
        closedBuilder: (context, openContainer) {                   // NEW
          onChangeOpenContainer(openContainer);                     // NEW
          return ColoredBox(                                        // NEW
            color: _backgroundColor,                                // NEW
            child: Padding(                                         // NEW
              padding: const EdgeInsets.all(16.0),                  // NEW
              child: Text(
                question ?? '',
                style: Theme.of(context).textTheme.displaySmall,
              ),
            ),
          );
        },
        openBuilder: (context, closeContainer) {                    // NEW
          return GameOverScreen(viewModel: viewModel);              // NEW
        },                                                          // NEW
      ),
    );
  }
}

4120f9395857d218.gif

8. Félicitations

Félicitations, vous avez ajouté des effets d'animation à une application Flutter et vous avez découvert les composants principaux du système d'animation de Flutter. Plus précisément, vous avez appris:

  • Utiliser un ImplicitlyAnimatedWidget
  • Utiliser un ExplicitlyAnimatedWidget
  • Appliquer des courbes et des splines à une animation
  • Utiliser des widgets de transition prédéfinis tels que AnimatedSwitcher ou PageRouteBuilder
  • Utiliser des effets d'animation prédéfinis sophistiqués du package animations, tels que FadeThroughTransition et OpenContainer
  • Personnaliser l'animation de transition par défaut, y compris en ajoutant la prise en charge de la prévisualisation du Retour sur Android

3026390ad413769c.gif

Et ensuite ?

Découvrez quelques-uns de ces ateliers de programmation:

Vous pouvez également télécharger l'exemple d'application d'animation, qui présente différentes techniques d'animation.

Complément d'informations

Vous trouverez d'autres ressources sur les animations sur flutter.dev:

Vous pouvez également consulter ces articles sur Medium:

Documents de référence