Animations dans Flutter

1. Introduction

Les animations sont un excellent moyen d'améliorer l'expérience utilisateur de votre application, de communiquer des informations importantes à l'utilisateur et de rendre votre application 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 s'exécutent automatiquement. Lorsque la valeur cible de l'animation change, elle exécute l'animation de la valeur actuelle à la valeur cible et affiche chaque valeur intermédiaire 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 ou arrêtée. Elle est composée d'une valeur représentant la valeur cible vers laquelle l'animation est en cours d'exécution et d'un état représentant la valeur actuelle que l'animation affiche à l'écran à un moment donné. Il s'agit d'une sous-classe de Listenable qui 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 tweens sont utilisés pour interpoler des valeurs entre une valeur de début et une valeur de fin. Ils peuvent représenter n'importe quel type, tel qu'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 le taux de changement au début ou à la fin de l'animation. Les courbes prennent 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.

Ce que vous allez faire

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

3026390ad413769c.gif

Vous apprendrez à :

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

Points abordés

Dans cet atelier de programmation, vous allez apprendre à :

  • Découvrez comment utiliser les effets animés implicites pour créer de superbes animations sans avoir à écrire beaucoup de code.
  • Découvrez comment utiliser les effets animés explicites pour configurer vos propres effets à l'aide de widgets animés prédéfinis tels que AnimatedSwitcher ou AnimationController.
  • Comment 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

  • 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 sous forme de fichier 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-questionnaire à choix multiples qui se compose de deux écrans suivant le modèle MVVM (Model-View-ViewModel). La QuestionScreen (vue) utilise la classe QuizViewModel (View-Model) pour poser des questions à choix multiples à l'utilisateur à partir de la classe QuestionBank (modèle).

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

fbb1e1f7b6c91e21.png

L'application ne prend pas encore en charge 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 Nouvelle partie.

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 des scores animé. Pour trouver des effets d'animation implicites courants, parcourez la documentation de l'API ImplicitlyAnimatedWidget.

206dd8d9c1fae95.gif

Créer le widget de tableau des 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 le import "scoreboard.dart" requis 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 correctement répondue, une autre étoile s'allume instantanément, sans animation. Dans les étapes suivantes, vous allez informer 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 le montant 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(isActive: score > i),                 // Edit this line.
        ],
      ),
    );
  }
}

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, seul le scale l'est, ce qui est fait 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. Cependant, il n'existe pas d'implémentation par défaut d'une icône étoile dans la classe AnimatedIcons.

Nous allons plutôt utiliser une autre sous-classe de ImplicitlyAnimatedWidget appelée TweenAnimationBuilder, qui prend 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 utiliserons un ColorTween, qui satisfait l'interface Tween requise pour créer notre effet d'animation.

Sélectionnez le widget Icon et utilisez l'action rapide "Wrap with Builder" (Envelopper avec Builder) dans votre IDE, puis 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);     // And modify this line.
        },
      ),
    );
  }
}

Effectuez un hot reload de l'application pour afficher 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. Vous pouvez ainsi modifier la couleur à tout moment (même pendant l'exécution de l'animation) et 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 accélèrent ou ralentissent.

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

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

Ces schémas (disponibles sur la page de documentation de l'API Curves) donnent une idée du 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 schémas montrent également un aperçu de l'apparence de différents effets d'animation lorsqu'ils utilisent une courbe d'accélération.

Créez un champ _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 ressort exagéré qui commence par un mouvement de ressort 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

Utiliser les outils de développement pour activer les animations lentes

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

Pour ouvrir les outils de développement, assurez-vous que l'application est exécutée 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 cliquant sur le bouton Ouvrir les outils de développement Flutter dans la fenêtre d'outils 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. Toutefois, au lieu de prendre une valeur cible, elles prennent un objet Animation comme paramètre. Cela les rend 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 enchaîné par défaut, mais vous pouvez le remplacer à l'aide du paramètre transitionBuilder. Le générateur 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> définissant 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 peut être utilisé pour convertir le Animation fourni par AnimatedSwitcher en Animation, à 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 cela utilise Tween.animate pour appliquer un Curve au Animation, puis pour le convertir d'un Tween qui va de 0,0 à 1,0 à un Tween qui passe de -0,1 à 0,0 sur l'axe X.

La classe Animation dispose également d'une fonction drive() qui prend n'importe quel Tween (ou Animatable) et le convertit en un nouveau Animation. Cela permet de "chaîner" les tweens, ce qui rend le code résultant 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 des animations explicites est qu'elles peuvent être 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 le layoutBuilder

Vous remarquerez peut-être un petit problème avec AnimationSwitcher. Lorsqu'un QuestionCard passe à une nouvelle question, il la dispose au centre de l'espace disponible pendant l'exécution de l'animation, mais lorsque l'animation s'arrête, le widget se place en haut de l'écran. Cela provoque une animation saccadée, car la position finale de la fiche de question ne correspond pas à la position pendant l'exécution de l'animation.

d77de181bdde58f7.gif

Pour résoudre ce problème, AnimatedSwitcher dispose également d'un paramètre layoutBuilder qui peut être utilisé pour définir la mise en page. Utilisez cette fonction pour configurer le générateur 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 de 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 prennent un objet Animation (contrairement à ImplicitlyAnimatedWidgets, qui prennent une value et une duration cibles).
  • 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 Tweens et Curves (à l'aide de CurveTween) à une animation.
  • Utilisez le paramètre layoutBuilder de AnimatedSwitcher pour ajuster la façon dont il dispose 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, tandis que les effets d'animation explicites nécessitent un Animation pour fonctionner correctement. Dans cette section, vous allez apprendre à créer vos propres objets Animation à l'aide d'un AnimationController, et à utiliser un TweenSequence pour combiner des Tween.

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 objet StatefulWidget
  2. Utilisez le mixin SingleTickerProviderStateMixin dans votre classe State pour fournir un Ticker à votre AnimationController.
  3. Initialisez 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 se reconstruit chaque fois que AnimationController avertit ses écouteurs, soit en utilisant AnimatedBuilder, soit en appelant manuellement listen() et setState.

Créez un fichier 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.

Le AnimatedBuilder garantit que l'arborescence des widgets est reconstruite chaque fois que AnimationController avertit ses écouteurs, et le widget Transform est utilisé pour appliquer un effet de rotation 3D afin de simuler le retournement d'une carte.

Pour utiliser ce widget, entourez chaque fiche de réponse d'un widget CardFlipEffect. Assurez-vous de fournir un key au widget Card :

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 une actualisation à chaud de l'application pour voir les cartes 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 State, 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 pour 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 à la fois. 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 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 un Animation qui applique le délai à l'aide d'un TweenSequence. Notez que cela n'utilise aucune utilité 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 les outils pour les développeurs, 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 passe 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>([              // Add from here...
      if (widget.delayAmount > 0)
        TweenSequenceItem(
          tween: ConstantTween<double>(0.0),
          weight: widget.delayAmount,
        ),
      TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1.0),
    ]).animate(_animationController);                          // To here.
  }

Enfin, remplacez l'animation de AnimationController par la nouvelle animation différée dans la méthode build.

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 relever 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 les effets sur un seul écran. Cependant, les animations peuvent également être utilisées 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 animations officiel 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 Navigator.

Pour personnaliser l'animation de transition, remplacez MaterialPageRoute par PageRouteBuilder et personnalisez l'animation de transition lorsque l'utilisateur passe de HomeScreen à QuestionScreen. Utilisez un FadeTransition (un widget explicitement animé) pour que le nouvel écran apparaisse en fondu au-dessus de l'écran précédent.

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(                                         // Add from here...
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
              return FadeTransition(
                opacity: animation,
                child: child,
              );
            },
      ),                                                        // To here.
    );
  },
  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(                     // Add from here...
                animation: animation,
                secondaryAnimation: secondaryAnimation,
                child: child,
              );                                                // To here.
            },
      ),
    );
  },
  child: Text('New Game'),
),

Personnaliser l'animation pour la prévisualisation du Retour

1c0558ffa3b76439.gif

La prévisualisation du Retour est une nouvelle fonctionnalité Android qui permet à l'utilisateur de jeter un coup d'œil derrière l'itinéraire ou l'application en cours pour voir ce qui se trouve derrière avant de naviguer. L'animation d'aperçu est déclenchée par la position du doigt de l'utilisateur lorsqu'il fait glisser son doigt vers l'arrière sur l'écran.

Flutter est compatible avec la prévisualisation du Retour système en activant la fonctionnalité au niveau du système lorsque Flutter n'a aucune route à supprimer de 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 lors de la navigation entre les routes d'une application Flutter. Un PageTransitionsBuilder spécial appelé PredictiveBackPageTransitionsBuilder écoute les gestes système de prévisualisation du Retour et pilote la transition de page en fonction de la progression du geste.

La prévisualisation du Retour n'est compatible qu'avec Android U et les versions ultérieures, mais Flutter reviendra gracieusement au comportement d'origine du geste Retour et à ZoomPageTransitionBuilder. Pour en savoir plus, consultez notre article de blog, y compris la section sur la configuration dans votre propre application.

Dans la configuration ThemeData de votre application, configurez PageTransitionsTheme pour qu'il utilise PredictiveBack sur Android et l'effet de transition "fade-through" du package d'animations sur les 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),
        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 le rappel Navigator.push() par un rappel MaterialPageRoute.

lib/home_screen.dart

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

Utiliser FadeThroughTransition pour changer la question actuelle

Le widget AnimatedSwitcher ne fournit qu'un seul Animation dans son rappel de compilateur. Pour résoudre ce problème, 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(                              // Add from here...
      layoutBuilder: (entries) {
        return Stack(alignment: Alignment.topCenter, children: entries);
      },
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          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,
          ),
        ),
      ),
    );
  }
}

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 un lien visuel 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 ViewModel, ajoutez un nouveau passage viewModel 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 Card par un widget OpenContainer du package animations, en ajoutant deux nouveaux champs pour le rappel viewModel et open container :

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 découvert les composants essentiels du système d'animation de Flutter. Plus précisément, vous avez appris à :

  • Utiliser une ImplicitlyAnimatedWidget
  • Utiliser une ExplicitlyAnimatedWidget
  • Appliquer Curves et Tweens à une animation
  • Utiliser des widgets de transition prédéfinis tels que AnimatedSwitcher ou PageRouteBuilder
  • Utiliser des effets d'animation sophistiqués prédéfinis à partir du package animations, tels que FadeThroughTransition et OpenContainer
  • Comment 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

Étape suivante

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

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

Documentation complémentaire

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

Vous pouvez également consulter les articles suivants sur Medium :

Documents de référence