Animazioni in Flutter

1. Introduzione

Le animazioni sono un ottimo modo per migliorare l'esperienza utente della tua app, comunicare informazioni importanti all'utente e rendere la tua app più raffinata e piacevole da usare.

Panoramica del framework di animazione di Flutter

Flutter mostra gli effetti di animazione ricostruendo una parte dell'albero dei widget in ogni frame. Fornisce effetti di animazione predefiniti e altre API per semplificare la creazione e la composizione delle animazioni.

  • Le animazioni implicite sono effetti di animazione predefiniti che eseguono automaticamente l'intera animazione. Quando il valore target dell'animazione cambia, l'animazione viene eseguita dal valore corrente a quello target e vengono visualizzati tutti i valori intermedi in modo che il widget venga animato in modo fluido. Alcuni esempi di animazioni implicite sono AnimatedSize, AnimatedScale e AnimatedPositioned.
  • Anche le animazioni esplicite sono effetti di animazione predefiniti, ma richiedono un oggetto Animation per funzionare. Alcuni esempi sono SizeTransition, ScaleTransition o PositionedTransition.
  • Animation è una classe che rappresenta un'animazione in esecuzione o interrotta ed è composta da un value che rappresenta il valore target a cui è in esecuzione l'animazione e dallo status, che rappresenta il valore corrente visualizzato dall'animazione sullo schermo in un determinato momento. È una sottoclasse di Listenable e notifica i suoi ascoltatori quando lo stato cambia durante l'esecuzione dell'animazione.
  • AnimationController è un modo per creare un'animazione e controllarne lo stato. I suoi metodi, come forward(), reset(), stop() e repeat(), possono essere utilizzati per controllare l'animazione senza dover definire l'effetto di animazione visualizzato, ad esempio la scala, le dimensioni o la posizione.
  • Le interpolazione lineari vengono utilizzate per interpolare i valori tra un valore iniziale e uno finale e possono rappresentare qualsiasi tipo, ad esempio un valore doppio, Offset o Color.
  • Le curve vengono utilizzate per regolare la velocità di variazione di un parametro nel tempo. Quando viene eseguita un'animazione, è comune applicare una curva di transizione per aumentare o diminuire la velocità di variazione all'inizio o alla fine dell'animazione. Le curve accettano un valore di input compreso tra 0,0 e 1,0 e restituiscono un valore di output compreso tra 0,0 e 1,0.

Cosa creerai

In questo codelab, creerai un quiz a scelta multipla con vari effetti e tecniche di animazione.

3026390ad413769c.gif

Scoprirai come...

  • Creare un widget che anima le dimensioni e il colore
  • Creare un effetto di ribaltamento di una carta 3D
  • Utilizzare effetti di animazione predefiniti sofisticati dal pacchetto di animazioni
  • Aggiungere il supporto del gesto Indietro predittivo disponibile nell'ultima versione di Android

Cosa imparerai a fare

In questo codelab imparerai:

  • Come utilizzare gli effetti animati in modo implicito per creare animazioni di grande impatto senza richiedere molto codice.
  • Come utilizzare gli effetti animati espliciti per configurare i tuoi effetti utilizzando widget animati predefiniti come AnimatedSwitcher o un AnimationController.
  • Come utilizzare AnimationController per definire un widget personalizzato che mostri un effetto 3D.
  • Come utilizzare il pacchetto animations per visualizzare effetti di animazione elaborati con una configurazione minima.

Che cosa ti serve

  • L'SDK Flutter
  • Un IDE, ad esempio VSCode o Android Studio / IntelliJ

2. Configura l'ambiente di sviluppo Flutter

Per completare questo lab, hai bisogno di due software: l'SDK Flutter e un editor.

Puoi eseguire il codelab utilizzando uno di questi dispositivi:

  • Un dispositivo Android (consigliato per l'implementazione del riavvolgimento predittivo nel passaggio 7) o iOS fisico collegato al computer e impostato sulla modalità Sviluppatore.
  • Il simulatore iOS (è richiesta l'installazione degli strumenti Xcode).
  • L'emulatore Android (richiede la configurazione in Android Studio).
  • Un browser (è necessario Chrome per il debug).
  • Un computer desktop Windows, Linux o macOS. Devi sviluppare sulla piattaforma in cui prevedi di eseguire il deployment. Pertanto, se vuoi sviluppare un'app desktop per Windows, devi eseguire lo sviluppo su Windows per accedere alla catena di build appropriata. Esistono requisiti specifici per il sistema operativo che sono descritti in dettaglio su docs.flutter.dev/desktop.

Verificare l'installazione

Per verificare che l'SDK Flutter sia configurato correttamente e che sia installata almeno una delle piattaforme di destinazione sopra indicate, utilizza lo strumento 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. Esegui l'app di avvio

Scaricare l'app iniziale

Utilizza git per clonare l'app iniziale dal repository flutter/samples su GitHub.

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

In alternativa, puoi scaricare il codice sorgente come file ZIP.

Eseguire l'app

Per eseguire l'app, utilizza il comando flutter run e specifica un dispositivo di destinazione, ad esempio android, ios o chrome. Per l'elenco completo delle piattaforme supportate, consulta la pagina Piattaforme supportate.

$ flutter run -d android

Puoi anche eseguire ed eseguire il debug dell'app utilizzando l'IDE che preferisci. Per ulteriori informazioni, consulta la documentazione ufficiale di Flutter.

Esplora il codice

L'app iniziale è un quiz a scelta multipla composto da due schermate che seguono il pattern di progettazione model-view-view-model o MVVM. QuestionScreen (Visualizzazione) utilizza la classe QuizViewModel (Modello di visualizzazione) per porre all'utente domande a scelta multipla della classe QuestionBank (Modello).

  • home_screen.dart: mostra una schermata con un pulsante Nuova partita
  • main.dart: configura MaterialApp per utilizzare Material 3 e mostrare la schermata Home
  • model.dart: definisce le classi di base utilizzate nell'app
  • question_screen.dart: mostra l'interfaccia utente del gioco a quiz
  • view_model.dart: memorizza lo stato e la logica del quiz, visualizzato da QuestionScreen

fbb1e1f7b6c91e21.png

L'app non supporta ancora effetti animati, ad eccezione della transizione di visualizzazione predefinita visualizzata dalla classe Navigator di Flutter quando l'utente preme il pulsante Nuova partita.

4. Utilizzare effetti di animazione impliciti

Le animazioni implicite sono un'ottima scelta in molte situazioni, poiché non richiedono alcuna configurazione speciale. In questa sezione aggiornerai il widget StatusBar in modo che mostri un tabellone animato. Per trovare gli effetti di animazione implicita più comuni, consulta la documentazione dell'API ImplicitlyAnimatedWidget.

206dd8d9c1fae95.gif

Creare il widget del tabellone non animato

Crea un nuovo file lib/scoreboard.dart con il seguente codice:

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,
            )
        ],
      ),
    );
  }
}

Aggiungi il widget Scoreboard agli elementi secondari del widget StatusBar, sostituendo i widget Text che in precedenza mostravano il punteggio e il numero totale di domande. L'editor dovrebbe aggiungere automaticamente il import "scoreboard.dart" richiesto nella parte superiore del file.

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
            ),
          ],
        ),
      ),
    );
  }
}

Questo widget mostra un'icona a forma di stella per ogni domanda. Quando viene data una risposta corretta a una domanda, si illumina immediatamente un'altra stella senza alcuna animazione. Nei passaggi successivi, aiuterai l'utente a capire che il suo punteggio è cambiato animandone le dimensioni e il colore.

Utilizzare un effetto di animazione implicito

Crea un nuovo widget denominato AnimatedStar che utilizza un widget AnimatedScale per modificare l'importo scale da 0.5 a 1.0 quando la stella diventa attiva:

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.

Ora, quando l'utente risponde correttamente a una domanda, il widget AnimatedStar aggiorna le sue dimensioni utilizzando un'animazione implicita. Il color di Icon non è animato qui, ma solo il scale, che viene eseguito dal widget AnimatedScale.

84aec4776e70b870.gif

Utilizzare un Tween per eseguire l'interpolazione tra due valori

Tieni presente che il colore del widget AnimatedStar cambia immediatamente dopo che il campo isActive diventa true.

Per ottenere un effetto di colore animato, puoi provare a utilizzare un widget AnimatedContainer (un'altra sottoclasse di ImplicitlyAnimatedWidget), perché può animare automaticamente tutti i suoi attributi, incluso il colore. Purtroppo, il nostro widget deve mostrare un'icona, non un contenitore.

Puoi anche provare AnimatedIcon, che implementa effetti di transizione tra le forme delle icone. Tuttavia, non esiste un'implementazione predefinita di un'icona a forma di stella nella classe AnimatedIcons.

Utilizzeremo invece un'altra sottoclasse di ImplicitlyAnimatedWidget chiamata TweenAnimationBuilder, che accetta un Tween come parametro. Un tween è una classe che prende due valori (begin e end) e calcola i valori intermedi, in modo che un'animazione possa visualizzarli. In questo esempio utilizzeremo un ColorTween, che soddisfa l'interfaccia Tween<Color> richiesta per creare l'effetto di animazione.

Seleziona il widget Icon e utilizza l'azione rapida "Inserisci un riquadro con il generatore di componenti" nell'IDE, poi cambia il nome in TweenAnimationBuilder. Poi specifica la durata e 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.
      ),
    );
  }
}

Ora ricarica l'app per vedere la nuova animazione.

8b0911f4af299a60.gif

Tieni presente che il valore end di ColorTween cambia in base al valore del parametro isActive. Questo accade perché TweenAnimationBuilder esegue di nuovo l'animazione ogni volta che il valore Tween.end cambia. In questo caso, la nuova animazione viene eseguita dal valore corrente dell'animazione al nuovo valore finale, il che ti consente di cambiare il colore in qualsiasi momento (anche durante l'esecuzione dell'animazione) e di visualizzare un effetto di animazione fluido con i valori intermedi corretti.

Applicare una curva

Entrambi questi effetti di animazione vengono eseguiti a una velocità costante, ma le animazioni sono spesso più interessanti e informative dal punto di vista visivo quando vengono accelerate o rallentate.

Un Curve applica una funzione di attenuazione, che definisce la velocità di variazione di un parametro nel tempo. Flutter è dotato di una raccolta di curve di transizione predefinite nella classe Curves, ad esempio easeIn o easeOut.

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

Questi diagrammi (disponibili nella pagina della documentazione dell'API Curves) forniscono un'idea di come funzionano le curve. Le curve convertono un valore di input compreso tra 0,0 e 1,0 (visualizzato sull'asse x) in un valore di output compreso tra 0,0 e 1,0 (visualizzato sull'asse y). Questi diagrammi mostrano anche un'anteprima dell'aspetto dei vari effetti di animazione quando viene utilizzata una curva di easing.

Crea un nuovo campo in AnimatedStar denominato _curve e passalo come parametro ai widget AnimatedScale e 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,
          );
        },
      ),
    );
  }
}

In questo esempio, la curva elasticOut fornisce un effetto di molla esagerato che inizia con un movimento a molla e si bilancia verso la fine.

8f84142bff312373.gif

Esegui il ricaricamento dell'app per vedere questa curva applicata a AnimatedSize e TweenAnimationBuilder.

206dd8d9c1fae95.gif

Utilizzare DevTools per attivare le animazioni lente

Per eseguire il debug di qualsiasi effetto di animazione, Flutter DevTools fornisce un modo per rallentare tutte le animazioni nell'app, in modo da poterle vedere più chiaramente.

Per aprire DevTools, assicurati che l'app sia in esecuzione in modalità di debug e apri l'ispezione dei widget selezionandola nella barra degli strumenti di debug in VSCode o selezionando il pulsante Apri Flutter DevTools nella finestra dello strumento di debug in IntelliJ / Android Studio.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

Una volta aperto l'ispezionatore dei widget, fai clic sul pulsante Rallenta animazioni nella barra degli strumenti.

adea0a16d01127ad.png

5. Utilizzare effetti di animazione espliciti

Come le animazioni implicite, le animazioni esplicite sono effetti di animazione predefiniti, ma invece di accettare un valore target, accettano un oggetto Animation come parametro. Questo li rende utili in situazioni in cui l'animazione è già definita da una transizione di navigazione, ad esempio AnimatedSwitcher o AnimationController.

Utilizzare un effetto di animazione esplicito

Per iniziare con un effetto animazione esplicito, racchiudi il widget Card in 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
    );
  }
}

Per impostazione predefinita, AnimatedSwitcher utilizza un effetto di transizione graduale, ma puoi sostituirlo utilizzando il parametro transitionBuilder. Il generatore di transizioni fornisce il widget secondario passato a AnimatedSwitcher e un oggetto Animation. Questa è un'ottima opportunità per utilizzare un'animazione esplicita.

Per questo codelab, la prima animazione esplicita che utilizzeremo è SlideTransition, che accetta un Animation<Offset> che definisce l'offset iniziale e finale tra cui si muoveranno i widget in entrata e in uscita.

Le animazioni interpolazioni dispongono di una funzione di supporto, animate(), che converte qualsiasi Animation in un altro Animation con l'animazione applicata. Ciò significa che un Tween<Offset> può essere utilizzato per convertire il Animation<double> fornito dal AnimatedSwitcher in un Animation<Offset>, da fornire al 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,
          ),
        ),
      ),
    );
  }
}

Tieni presente che viene utilizzato Tween.animate per applicare un Curve a Animation e poi per convertirlo da un Tween<double> che va da 0,0 a 1,0 a un Tween<Offset> che passa da -0,1 a 0,0 sull'asse x.

In alternativa, la classe Animation ha una funzione drive() che prende qualsiasi Tween (o Animatable) e lo converte in un nuovo Animation. In questo modo, i tween possono essere "incatenati", rendendo il codice risultante più conciso:

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 altro vantaggio dell'utilizzo di animazioni esplicite è che possono essere facilmente combinate. Aggiungi un'altra animazione esplicita, FadeTransition, che utilizza la stessa animazione curva inserendo il 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
  },

Personalizzare layoutBuilder

Potresti notare un piccolo problema con AnimationSwitcher. Quando una scheda domanda passa a una nuova domanda, la dispone al centro dello spazio disponibile mentre l'animazione è in esecuzione, ma quando l'animazione viene interrotta, il widget si aggancia alla parte superiore dello schermo. Ciò causa un'animazione discontinua perché la posizione finale della scheda della domanda non corrisponde a quella durante l'esecuzione dell'animazione.

d77de181bdde58f7.gif

Per risolvere il problema, AnimatedSwitcher dispone anche di un parametro layoutBuilder, che può essere utilizzato per definire il layout. Utilizza questa funzione per configurare lo strumento per la creazione del layout in modo che allinei la scheda alla parte superiore dello schermo:

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,
        ],
      );
    },

Questo codice è una versione modificata di defaultLayoutBuilder della classe AnimatedSwitcher, ma utilizza Alignment.topCenter anziché Alignment.center.

Riepilogo

  • Le animazioni esplicite sono effetti di animazione che richiedono un oggetto Animation (a differenza di ImplicitlyAnimatedWidgets, che richiedono un valore target e una durata).
  • La classe Animation rappresenta un'animazione in esecuzione, ma non definisce un effetto specifico.
  • Utilizza Tween().animate o Animation.drive() per applicare Tween e Curve (utilizzando CurveTween) a un'animazione.
  • Utilizza il parametro layoutBuilder di AnimatedSwitcher per modificare il modo in cui vengono disposti i relativi elementi secondari.

6. Controllare lo stato di un'animazione

Finora, ogni animazione è stata eseguita automaticamente dal framework. Le animazioni implicite vengono eseguite automaticamente e gli effetti di animazione espliciti richiedono un'animazione per funzionare correttamente. In questa sezione imparerai a creare i tuoi oggetti Animation utilizzando un AnimationController e a utilizzare una TweenSequence per combinare i Tween.

Eseguire un'animazione utilizzando un AnimationController

Per creare un'animazione utilizzando un AnimationController, devi seguire questi passaggi:

  1. Creare un StatefulWidget
  2. Utilizza il mixin SingleTickerProviderStateMixin nella classe State per fornire un Ticker all'AnimationController
  3. Inizializza AnimationController nel metodo di ciclo di vita initState, fornendo l'oggetto State corrente al parametro vsync (TickerProvider).
  4. Assicurati che il widget venga ricostruito ogni volta che AnimationController invia una notifica ai suoi ascoltatori, utilizzando AnimatedBuilder o chiamando listen() e setState manualmente.

Crea un nuovo file, flip_effect.dart, e copia e incolla il seguente codice:

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,
    );
  }
}

Questa classe configura un AnimationController ed esegue di nuovo l'animazione ogni volta che il framework chiama didUpdateWidget per notificare che la configurazione del widget è cambiata e che potrebbe esserci un nuovo widget secondario.

AnimatedBuilder garantisce che l'albero dei widget venga ricostruito ogni volta che AnimationController invia una notifica ai suoi ascoltatori e il widget Transform viene utilizzato per applicare un effetto di rotazione 3D per simulare la rotazione di una scheda.

Per utilizzare questo widget, inserisci ogni scheda di risposta in un widget CardFlipEffect. Assicurati di fornire un key al widget Scheda:

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
      );
    }),
  );
}

Ora ricarica l'app per vedere le schede delle risposte girare utilizzando il widget CardFlipEffect.

5455def725b866f6.gif

Potresti notare che questa classe assomiglia molto a un effetto di animazione esplicito. In effetti, spesso è buona norma estendere direttamente la classe AnimatedWidget per implementare la tua versione. Purtroppo, poiché questo corso deve memorizzare il widget precedente nel proprio stato, deve utilizzare un StatefulWidget. Per scoprire di più sulla creazione di effetti di animazione espliciti, consulta la documentazione dell'API per AnimatedWidget.

Aggiungere un ritardo utilizzando TweenSequence

In questa sezione, aggiungerai un ritardo al widget CardFlipEffect in modo che ogni scheda venga girata una alla volta. Per iniziare, aggiungi un nuovo campo denominato 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();
}

Aggiungi delayAmount al metodo di compilazione 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]),

Poi, in _CardFlipEffectState, crea una nuova animazione che applichi il ritardo utilizzando un TweenSequence. Tieni presente che non vengono utilizzate utilità della libreria dart:async, come Future.delayed. Questo perché il ritardo fa parte dell'animazione e non è qualcosa che il widget controlla esplicitamente quando utilizza AnimationController. Ciò semplifica il debug dell'effetto animazione quando attivi le animazioni lente in DevTools, poiché utilizza lo stesso TickerProvider.

Per utilizzare un TweenSequence, crea due TweenSequenceItem, uno contenente un ConstantTween che mantiene l'animazione a 0 per una durata relativa e un Tween normale che va da 0.0 a 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
  }

Infine, sostituisci l'animazione di AnimationController con la nuova animazione ritardata nel metodo di compilazione.

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,
  );
}

Ora ricarica l'app e guarda le schede girare una per una. Per una sfida, prova a modificare la prospettiva dell'effetto 3D fornito dal widget Transform.

28b5291de9b3f55f.gif

7. Utilizzare transizioni di navigazione personalizzate

Finora abbiamo visto come personalizzare gli effetti in una singola schermata, ma un altro modo per utilizzare le animazioni è per passare da una schermata all'altra. In questa sezione imparerai ad applicare effetti di animazione alle transizioni tra schermate utilizzando gli effetti di animazione integrati e quelli predefiniti più sofisticati forniti dal pacchetto ufficiale animations su pub.dev

Animare una transizione di navigazione

La classe PageRouteBuilder è un Route che ti consente di personalizzare l'animazione di transizione. Ti consente di sostituire il relativo callback transitionBuilder, che fornisce due oggetti Animation, che rappresentano l'animazione in entrata e in uscita eseguita dal Navigator.

Per personalizzare l'animazione di transizione, sostituisci MaterialPageRoute con PageRouteBuilder e per personalizzare l'animazione di transizione quando l'utente passa da HomeScreen a QuestionScreen. Utilizza una transizione di dissolvenza (un widget animato in modo esplicito) per far apparire la nuova schermata sopra quella precedente.

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'),
),

Il pacchetto di animazioni fornisce effetti di animazione predefiniti sofisticati, come FadeThroughTransition. Importa il pacchetto di animazioni e sostituisci FadeTransition con il 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'),
),

Personalizzare l'animazione Indietro predittivo

1c0558ffa3b76439.gif

Indietro predittivo è una nuova funzionalità di Android che consente all'utente di dare un'occhiata dietro il percorso o l'app corrente per vedere cosa c'è dietro prima di navigare. L'animazione di anteprima è basata sulla posizione del dito dell'utente mentre trascina indietro sullo schermo.

Flutter supporta il pulsante Indietro predittivo di sistema attivando la funzionalità a livello di sistema quando Flutter non ha route da visualizzare nello stack di navigazione, in altre parole quando un pulsante Indietro fa uscire dall'app. Questa animazione è gestita dal sistema e non da Flutter stesso.

Flutter supporta anche il gesto Indietro predittivo quando si passa da una route all'altra all'interno di un'app Flutter. Un PageTransitionsBuilder speciale chiamato PredictiveBackPageTransitionsBuilder ascolta i gesti Indietro predittivi di sistema e gestisce la transizione di pagina con l'avanzamento del gesto.

Il pulsante Indietro predittivo è supportato solo in Android U e versioni successive, ma Flutter tornerà in modo elegante al comportamento originale del gesto Indietro e a ZoomPageTransitionBuilder. Per saperne di più, consulta il nostro post del blog, che include una sezione su come configurarlo nella tua app.

Nella configurazione di ThemeData per la tua app, configura PageTransitionsTheme in modo da utilizzare la funzionalità Indietro predittiva su Android e l'effetto di transizione di dissolvenza dal pacchetto di animazioni su altre piattaforme:

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(),
    );
  }
}

Ora puoi modificare la chiamata Navigator.push() in una 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'),
),

Utilizzare FadeThroughTransition per cambiare la domanda corrente

Il widget AnimatedSwitcher fornisce una sola animazione nel suo callback del builder. Per risolvere il problema, il pacchetto animations fornisce 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,
          ),
        ),
      ),
    );
  }
}

Utilizzare OpenContainer

77358e5776eb104c.png

Il widget OpenContainer del pacchetto animations fornisce un effetto di animazione di trasformazione del contenitore che si espande per creare un collegamento visivo tra due widget.

Il widget restituito da closedBuilder viene visualizzato inizialmente e si espande nel widget restituito da openBuilder quando si tocca il contenitore o quando viene chiamato il callback openContainer.

Per collegare il callback openContainer al modello di visualizzazione, aggiungi un nuovo passaggio del viewModel nel widget QuestionCard e memorizza un callback che verrà utilizzato per mostrare la schermata "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
}

Aggiungi un nuovo 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);
              },
            ),
          ],
        ),
      ),
    );
  }
}

Nel widget QuestionCard, sostituisci la scheda con un widget OpenContainer del pacchetto di animazioni, aggiungendo due nuovi campi per il callback del viewModel e dell'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. Complimenti

Congratulazioni, hai aggiunto correttamente effetti di animazione a un'app Flutter e hai imparato a conoscere i componenti principali del sistema di animazione di Flutter. Nello specifico, hai appreso:

  • Come utilizzare un ImplicitlyAnimatedWidget
  • Come utilizzare un widget esplicitamente animato
  • Come applicare curve e tween a un'animazione
  • Come utilizzare i widget di transizione predefiniti come AnimatedSwitcher o PageRouteBuilder
  • Come utilizzare effetti di animazione predefiniti sofisticati del pacchetto animations, come FadeThroughTransition e OpenContainer
  • Come personalizzare l'animazione di transizione predefinita, inclusa l'aggiunta del supporto per Indietro predittivo su Android.

3026390ad413769c.gif

Passaggi successivi

Dai un'occhiata ad alcuni di questi codelab:

In alternativa, scarica l'app di esempio di animazioni, che mostra varie tecniche di animazione.

Letture aggiuntive

Puoi trovare altre risorse sulle animazioni su flutter.dev:

In alternativa, consulta questi articoli su Medium:

Documentazione di riferimento