Animacje w technologii Flutter

1. Wprowadzenie

Animacje to świetny sposób na zwiększenie wygody użytkowników aplikacji, przekazywanie im ważnych informacji oraz sprawienie, że aplikacja będzie bardziej dopracowana i przyjemna w użyciu.

Przegląd platformy animacji Fluttera

Flutter wyświetla efekty animacji, przebudowując część drzewa widżetów w każdej klatce. Zawiera gotowe efekty animacji i inne interfejsy API, które ułatwiają tworzenie i komponowanie animacji.

  • Animacje domyślne to gotowe efekty animacji, które są odtwarzane automatycznie w całości. Gdy wartość celu animacji się zmieni, animacja zostanie uruchomiona od bieżącej wartości do wartości docelowej, a każda wartość pośrednia będzie wyświetlana, aby widżet był animowany płynnie. Przykłady animacji domyślnych to AnimatedSize, AnimatedScaleAnimatedPositioned.
  • Animacje jawne to również gotowe efekty animacji, ale wymagają obiektu Animation, aby działać. Przykłady: SizeTransition, ScaleTransition lub PositionedTransition.
  • Animation to klasa reprezentująca działającą lub zatrzymaną animację. Składa się z wartości reprezentującej wartość docelową, do której zmierza animacja, oraz stanu, który reprezentuje aktualną wartość wyświetlaną przez animację na ekranie w danym momencie. Jest to podklasa klasy Listenable, która powiadamia odbiorców o zmianach stanu podczas działania animacji.
  • AnimationController to sposób na utworzenie animacji i kontrolowanie jej stanu. Jego metody, takie jak forward(), reset(), stop()repeat(), mogą służyć do sterowania animacją bez konieczności definiowania wyświetlanego efektu animacji, np. skali, rozmiaru czy pozycji.
  • Animacje pośrednie służą do interpolacji wartości między wartością początkową a końcową i mogą reprezentować dowolny typ, np. liczbę zmiennoprzecinkową podwójnej precyzji, Offset lub Color.
  • Krzywe służą do dostosowywania tempa zmian parametru w czasie. Podczas animacji często stosuje się krzywą wygładzania, aby na początku lub na końcu animacji przyspieszyć lub spowolnić tempo zmian. Krzywe przyjmują wartość wejściową z zakresu od 0,0 do 1,0 i zwracają wartość wyjściową z zakresu od 0,0 do 1,0.

Co utworzysz

W ramach tego ćwiczenia utworzysz quiz jednokrotnego wyboru z różnymi efektami i technikami animacji.

3026390ad413769c.gif

Dowiesz się, jak:

  • Tworzenie widżetu, który animuje swój rozmiar i kolor
  • Tworzenie efektu odwracania karty 3D
  • Korzystaj z gotowych efektownych animacji z pakietu animacji.
  • Dodanie obsługi gestu przewidywanego przejścia wstecz dostępnego w najnowszej wersji Androida

Czego się nauczysz

Z tego ćwiczenia dowiesz się:

  • Jak używać efektów animowanych w sposób dorozumiany, aby uzyskać atrakcyjne animacje bez konieczności pisania dużej ilości kodu.
  • Jak używać efektów animowanych do konfigurowania własnych efektów za pomocą gotowych animowanych widżetów, takich jak AnimatedSwitcher lub AnimationController.
  • Jak używać AnimationController do definiowania własnego widżetu, który wyświetla efekt 3D.
  • Jak używać animationspakietu, aby wyświetlać efektowne animacje przy minimalnej konfiguracji.

Czego potrzebujesz

  • Pakiet Flutter SDK
  • IDE, np. VSCode lub Android Studio / IntelliJ

2. Konfigurowanie środowiska programistycznego Fluttera

Aby ukończyć ten moduł, potrzebujesz 2 programów: pakietu SDK Flutteredytora.

Codelab możesz uruchomić na dowolnym z tych urządzeń:

  • Fizyczne urządzenie z Android (zalecane do wdrożenia przewidywanego przejścia wstecz w kroku 7) lub iOS podłączone do komputera i ustawione w trybie programisty.
  • Symulator iOS (wymaga zainstalowania narzędzi Xcode).
  • Android Emulator (wymaga konfiguracji w Android Studio).
  • przeglądarka (do debugowania wymagana jest Chrome);
  • Komputer stacjonarny z systemem Windows, Linux lub macOS. Musisz tworzyć aplikację na platformie, na której zamierzasz ją wdrożyć. Jeśli chcesz opracować aplikację na komputery z systemem Windows, musisz to zrobić na komputerze z tym systemem, aby mieć dostęp do odpowiedniego łańcucha kompilacji. Istnieją wymagania dotyczące konkretnych systemów operacyjnych, które są szczegółowo opisane na stronie docs.flutter.dev/desktop.

Sprawdzanie instalacji

Aby sprawdzić, czy pakiet Flutter SDK jest prawidłowo skonfigurowany i czy masz zainstalowaną co najmniej jedną z powyższych platform docelowych, użyj narzędzia 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. Uruchamianie aplikacji startowej

Pobieranie aplikacji startowej

Użyj git, aby sklonować aplikację początkową z repozytorium flutter/samples w GitHubie.

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

Możesz też pobrać kod źródłowy jako plik ZIP.

Uruchamianie aplikacji

Aby uruchomić aplikację, użyj polecenia flutter run i określ urządzenie docelowe, np. android, ios lub chrome. Pełną listę obsługiwanych platform znajdziesz na stronie Obsługiwane platformy.

flutter run -d android

Możesz też uruchomić aplikację i ją debugować w wybranym środowisku IDE. Więcej informacji znajdziesz w oficjalnej dokumentacji Fluttera.

Przeglądanie kodu

Aplikacja startowa to quiz wielokrotnego wyboru składający się z 2 ekranów, które są zgodne z wzorcem projektowym MVVM (model-view-view-model). Klasa QuestionScreen (Widok) używa klasy QuizViewModel (Model widoku), aby zadawać użytkownikowi pytania wielokrotnego wyboru z klasy QuestionBank (Model).

  • home_screen.dart – wyświetla ekran z przyciskiem Nowa gra.
  • main.dart – konfiguruje MaterialApp, aby używać Material 3 i wyświetlać ekran główny.
  • model.dart – definiuje podstawowe klasy używane w całej aplikacji.
  • question_screen.dart – wyświetla interfejs gry quizowej.
  • view_model.dart – przechowuje stan i logikę gry quizowej, wyświetlanej przez QuestionScreen

fbb1e1f7b6c91e21.png

Aplikacja nie obsługuje jeszcze żadnych efektów animacji z wyjątkiem domyślnego przejścia widoku wyświetlanego przez klasę Navigator Fluttera, gdy użytkownik naciśnie przycisk Nowa gra.

4. Korzystanie z efektów animacji domyślnej

Animacje domyślne są w wielu sytuacjach doskonałym wyborem, ponieważ nie wymagają specjalnej konfiguracji. W tej sekcji zaktualizujesz widżet StatusBar, aby wyświetlał animowaną tablicę wyników. Aby znaleźć typowe efekty animacji domyślnych, zapoznaj się z dokumentacją API ImplicitlyAnimatedWidget.

206dd8d9c1fae95.gif

Tworzenie widżetu nieanimowanej tablicy wyników

Utwórz nowy plik lib/scoreboard.dart z tym kodem:

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

Następnie dodaj widżet Scoreboard do elementów podrzędnych widżetu StatusBar, zastępując widżety Text, które wcześniej wyświetlały wynik i łączną liczbę pytań. Edytor powinien automatycznie dodać wymagany tag import "scoreboard.dart" na początku pliku.

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

Ten widżet wyświetla ikonę gwiazdki przy każdym pytaniu. Gdy pytanie zostanie poprawnie rozwiązane, natychmiast zapala się kolejna gwiazdka bez animacji. W kolejnych krokach poinformujesz użytkownika o zmianie wyniku, animując jego rozmiar i kolor.

Używanie efektu animacji domyślnej

Utwórz nowy widżet o nazwie AnimatedStar, który używa widżetu AnimatedScale do zmiany kwoty scale0.5 na 1.0, gdy gwiazdka stanie się aktywna:

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.

Gdy użytkownik odpowie na pytanie prawidłowo, widżet AnimatedStar zmieni rozmiar za pomocą animacji domyślnej. Iconcolor nie jest tu animowany, tylko scale, co jest realizowane przez widżet AnimatedScale.

84aec4776e70b870.gif

Używanie animacji Tween do interpolacji między dwiema wartościami

Zauważ, że kolor widżetu AnimatedStar zmienia się natychmiast po zmianie wartości pola isActive na „true”.

Aby uzyskać animowany efekt kolorystyczny, możesz użyć widżetu AnimatedContainer (który jest inną podklasą ImplicitlyAnimatedWidget), ponieważ może on automatycznie animować wszystkie swoje atrybuty, w tym kolor. Niestety nasz widżet musi wyświetlać ikonę, a nie kontener.

Możesz też wypróbować AnimatedIcon, który implementuje efekty przejścia między kształtami ikon. W klasie AnimatedIcons nie ma jednak domyślnej implementacji ikony gwiazdki.

Zamiast tego użyjemy innej podklasy ImplicitlyAnimatedWidget o nazwie TweenAnimationBuilder, która przyjmuje parametr Tween. Tween to klasa, która przyjmuje 2 wartości (begin i end) i oblicza wartości pośrednie, aby animacja mogła je wyświetlać. W tym przykładzie użyjemy ColorTween, który spełnia wymagania interfejsu Tween potrzebne do utworzenia efektu animacji.

Wybierz widżet Icon i użyj szybkiego działania „Wrap with Builder” (Owiń za pomocą narzędzia do tworzenia) w środowisku IDE, a następnie zmień nazwę na TweenAnimationBuilder. Następnie podaj czas trwania i 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.
        },
      ),
    );
  }
}

Teraz włącz szybkie przeładowanie aplikacji, aby zobaczyć nową animację.

8b0911f4af299a60.gif

Zauważ, że wartość end naszego parametru ColorTween zmienia się w zależności od wartości parametru isActive. Dzieje się tak, ponieważ TweenAnimationBuilder ponownie uruchamia animację za każdym razem, gdy zmienia się wartość Tween.end. W takim przypadku nowa animacja będzie się odtwarzać od bieżącej wartości animacji do nowej wartości końcowej, co pozwoli Ci w dowolnym momencie zmienić kolor (nawet podczas odtwarzania animacji) i wyświetlić płynny efekt animacji z prawidłowymi wartościami pośrednimi.

Stosowanie krzywej

Oba te efekty animacji działają ze stałą szybkością, ale animacje są często bardziej interesujące wizualnie i informatywne, gdy przyspieszają lub zwalniają.

Curve stosuje funkcję wygładzania, która określa tempo zmian parametru w czasie. Flutter zawiera zbiór gotowych krzywych łagodzenia w klasie Curves, takich jak easeIn czy easeOut.

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

Te diagramy (dostępne na stronie dokumentacji interfejsu API Curves) pokazują, jak działają krzywe. Krzywe przekształcają wartość wejściową z zakresu od 0,0 do 1,0 (wyświetlaną na osi X) na wartość wyjściową z zakresu od 0,0 do 1,0 (wyświetlaną na osi Y). Schematy te pokazują też podgląd różnych efektów animacji, gdy używają one krzywej łagodzenia.

Utwórz nowe pole w widżecie AnimatedStar o nazwie _curve i przekaż je jako parametr do widżetów AnimatedScaleTweenAnimationBuilder.

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

W tym przykładzie krzywa elasticOut zapewnia przesadny efekt sprężyny, który zaczyna się od ruchu sprężyny i pod koniec się stabilizuje.

8f84142bff312373.gif

Gorące przeładowanie aplikacji, aby zobaczyć, jak ta krzywa wpływa na wartości AnimatedSizeTweenAnimationBuilder.

206dd8d9c1fae95.gif

Włączanie wolnych animacji za pomocą Narzędzi deweloperskich

Aby debugować dowolny efekt animacji, Narzędzia deweloperskie Flutter umożliwiają spowolnienie wszystkich animacji w aplikacji, dzięki czemu można je lepiej zobaczyć.

Aby otworzyć Narzędzia deweloperskie, upewnij się, że aplikacja działa w trybie debugowania, i otwórz Inspektora widżetów, wybierając go na pasku narzędzi debugowania w VSCode lub klikając przycisk Otwórz Narzędzia deweloperskie Flutteraoknie narzędzi debugowania w IntelliJ / Android Studio.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

Po otwarciu inspektora widżetów kliknij przycisk Zwolnij animacje na pasku narzędzi.

adea0a16d01127ad.png

5. Używanie efektów animacji

Podobnie jak animacje domyślne, animacje jawne to gotowe efekty animacji, ale zamiast wartości docelowej przyjmują jako parametr obiekt Animation. Dzięki temu są przydatne w sytuacjach, gdy animacja jest już zdefiniowana przez przejście nawigacyjne, AnimatedSwitcher lub AnimationController.

Używanie efektu animacji

Aby rozpocząć korzystanie z efektu animacji, umieść widżet Card w tagu 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 domyślnie używa efektu przenikania, ale możesz go zastąpić za pomocą parametru transitionBuilder. Funkcja tworząca przejście udostępnia widżet podrzędny przekazany do AnimatedSwitcher i obiekt Animation. To świetna okazja, aby użyć wyraźnej animacji.

W tym laboratorium kodu pierwszą jawną animacją, której użyjemy, będzie SlideTransition. Przyjmuje ona argument Animation<Offset>, który określa początkowe i końcowe przesunięcie, między którymi będą się poruszać przychodzące i wychodzące widżety.

Tweeny mają funkcję pomocniczą animate(), która przekształca dowolny Animation w inny Animation z zastosowanym tweenem. Oznacza to, że Tween może służyć do przekształcania Animation dostarczonego przez AnimatedSwitcherAnimation, który będzie przekazywany do widżetu 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,
          ),
        ),
      ),
    );
  }
}

Zwróć uwagę, że używa to funkcji Tween.animate, aby zastosować funkcję Curve do funkcji Animation, a następnie przekształcić ją z funkcji Tween, która przyjmuje wartości od 0,0 do 1,0, na funkcję Tween, która przyjmuje wartości od -0,1 do 0,0 na osi x.

Klasa Animation ma też funkcję drive(), która przyjmuje dowolny typ Tween (lub Animatable) i przekształca go w nowy typ Animation. Umożliwia to „łączenie” animacji, dzięki czemu wynikowy kod jest bardziej zwięzły:

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

Kolejną zaletą korzystania z animacji jawnych jest to, że można je łączyć. Dodaj kolejną animację jawną FadeTransition, która używa tej samej animacji krzywej, opakowując widżet 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
  },

Dostosowywanie funkcji layoutBuilder

Możesz zauważyć drobny problem z AnimationSwitcher. Gdy QuestionCard przełącza się na nowe pytanie, podczas animacji wyświetla je na środku dostępnego miejsca, ale po zatrzymaniu animacji widżet przeskakuje na górę ekranu. Powoduje to niepłynną animację, ponieważ końcowa pozycja karty z pytaniem nie pasuje do pozycji podczas animacji.

d77de181bdde58f7.gif

Aby rozwiązać ten problem, element AnimatedSwitcher ma też parametr layoutBuilder, którego można użyć do zdefiniowania układu. Użyj tej funkcji, aby skonfigurować narzędzie do tworzenia układu w taki sposób, aby karta była wyrównana do góry ekranu:

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

Ten kod jest zmodyfikowaną wersją funkcji defaultLayoutBuilder z klasy AnimatedSwitcher, ale używa Alignment.topCenter zamiast Alignment.center.

Podsumowanie

  • Animacje jawne to efekty animacji, które przyjmują obiekt Animation (w przeciwieństwie do ImplicitlyAnimatedWidgets, które przyjmują docelowy atrybut value i duration).
  • Klasa Animation reprezentuje działającą animację, ale nie definiuje konkretnego efektu.
  • Użyj Tween().animate lub Animation.drive(), aby zastosować Tweens i Curves (za pomocą CurveTween) do animacji.
  • Użyj parametru AnimatedSwitcherlayoutBuilder, aby dostosować sposób rozmieszczenia elementów podrzędnych.

6. Sterowanie stanem animacji

Do tej pory każda animacja była uruchamiana automatycznie przez framework. Animacje domyślne działają automatycznie, a efekty animacji jawnych wymagają prawidłowego działania Animation. W tej sekcji dowiesz się, jak tworzyć własne obiekty Animation za pomocą AnimationController i jak łączyć ze sobą obiekty Tween za pomocą TweenSequence.

Uruchamianie animacji za pomocą klasy AnimationController

Aby utworzyć animację za pomocą klasy AnimationController, wykonaj te czynności:

  1. Utwórz StatefulWidget
  2. Użyj miksu SingleTickerProviderStateMixin w klasie State, aby przekazać Ticker do AnimationController.
  3. Zainicjuj AnimationController w metodzie cyklu życia initState, przekazując bieżący obiekt State do parametru vsync (TickerProvider).
  4. Sprawdź, czy widżet jest ponownie tworzony za każdym razem, gdy AnimationController powiadamia swoich odbiorców, używając AnimatedBuilder lub wywołując ręcznie listen()setState.

Utwórz nowy plik flip_effect.dart i skopiuj do niego ten kod:

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

Ta klasa konfiguruje AnimationController i ponownie uruchamia animację za każdym razem, gdy platforma wywołuje didUpdateWidget, aby powiadomić ją o zmianie konfiguracji widżetu i możliwości pojawienia się nowego widżetu podrzędnego.

AnimatedBuilder zapewnia, że drzewo widżetów jest przebudowywane za każdym razem, gdy AnimationController powiadamia swoich odbiorców, a widżet Transform służy do stosowania efektu obrotu 3D, aby symulować odwracanie karty.

Aby użyć tego widżetu, umieść każdą kartę odpowiedzi w tagu CardFlipEffect. Podaj key do widżetu 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
      );
    }),
  );
}

Teraz włącz szybkie przeładowanie aplikacji, aby zobaczyć, jak karty z odpowiedziami odwracają się za pomocą widżetu CardFlipEffect.

5455def725b866f6.gif

Możesz zauważyć, że ta klasa bardzo przypomina efekt animacji jawnej. W zasadzie często warto bezpośrednio rozszerzyć klasę AnimatedWidget, aby wdrożyć własną wersję. Niestety, ponieważ ta klasa musi przechowywać poprzedni widżet w swoim polu State, musi używać StatefulWidget. Więcej informacji o tworzeniu własnych efektów animacji znajdziesz w dokumentacji interfejsu API AnimatedWidget.

Dodawanie opóźnienia za pomocą TweenSequence

W tej sekcji dodasz opóźnienie do widżetu CardFlipEffect, aby każda karta odwracała się pojedynczo. Aby rozpocząć, dodaj nowe pole o nazwie 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();
}

Następnie dodaj delayAmount do metody tworzenia 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]),

Następnie w _CardFlipEffectState utwórz nowy Animation, który zastosuje opóźnienie za pomocą TweenSequence. Zwróć uwagę, że nie korzysta on z żadnych narzędzi z biblioteki dart:async, takich jak Future.delayed. Dzieje się tak, ponieważ opóźnienie jest częścią animacji, a nie czymś, co widżet wyraźnie kontroluje, gdy używa AnimationController. Ułatwia to debugowanie efektu animacji po włączeniu wolnych animacji w Narzędziach deweloperskich, ponieważ używa tego samego TickerProvider.

Aby użyć TweenSequence, utwórz 2 TweenSequenceItem: jeden z ConstantTween, który utrzymuje animację na poziomie 0 przez względny czas trwania, i zwykły Tween, który przechodzi od 0.0 do 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.
  }

Na koniec zastąp animację AnimationController nową animacją opóźnioną w metodzie 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,
  );
}

Teraz włącz gorące przeładowanie aplikacji i obserwuj, jak karty odwracają się jedna po drugiej. Spróbuj zmienić perspektywę efektu 3D zapewnianego przez widżet Transform.

28b5291de9b3f55f.gif

7. Używanie niestandardowych przejść nawigacyjnych

Do tej pory widzieliśmy, jak dostosowywać efekty na jednym ekranie, ale animacje można też wykorzystywać do przechodzenia między ekranami. Z tej sekcji dowiesz się, jak stosować efekty animacji do przejść między ekranami za pomocą wbudowanych efektów animacji i zaawansowanych gotowych efektów animacji udostępnianych przez oficjalny pakiet animations na stronie pub.dev.

Animowanie przejścia nawigacji

Klasa PageRouteBuilder to Route, która umożliwia dostosowanie animacji przejścia. Umożliwia to zastąpienie wywołania zwrotnego transitionBuilder, które udostępnia 2 obiekty Animation reprezentujące animację przychodzącą i wychodzącą uruchamianą przez Navigator.

Aby dostosować animację przejścia, zastąp MaterialPageRoute ciągiem PageRouteBuilder, a aby dostosować animację przejścia, gdy użytkownik przechodzi z HomeScreen do QuestionScreen. Użyj FadeTransition (wyraźnie animowanego widżetu), aby nowy ekran pojawił się na poprzednim z efektem zanikania.

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

Pakiet animacji zawiera gotowe efekty animacji, takie jak FadeThroughTransition. Zaimportuj pakiet animacji i zastąp widżet FadeTransition widżetem 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'),
),

Dostosowywanie animacji przewidywanego przejścia wstecz

1c0558ffa3b76439.gif

Przewidywane przejście wstecz to nowa funkcja Androida, która umożliwia użytkownikowi zajrzenie za bieżącą trasę lub aplikację, aby zobaczyć, co się za nią kryje, zanim przejdzie dalej. Animacja podglądu jest zależna od położenia palca użytkownika podczas przeciągania po ekranie.

Flutter obsługuje systemowe przewidywane przejście wstecz, włączając tę funkcję na poziomie systemu, gdy Flutter nie ma tras do wycofania ze stosu nawigacji, czyli gdy powrót spowoduje zamknięcie aplikacji. Animacja jest obsługiwana przez system, a nie przez samą platformę Flutter.

Flutter obsługuje też przewidywane przejście wstecz podczas przechodzenia między trasami w aplikacji Flutter. Specjalny widżet PageTransitionsBuilder o nazwie PredictiveBackPageTransitionsBuilder nasłuchuje gestów systemowych przewidywanego przejścia wstecz i steruje przejściem strony w zależności od postępu gestu.

Przewidywane przejście wstecz jest obsługiwane tylko w Androidzie U i nowszych wersjach, ale Flutter płynnie powróci do pierwotnego zachowania gestu powrotu i ZoomPageTransitionBuilder. Więcej informacji, w tym sekcję o tym, jak skonfigurować tę funkcję w swojej aplikacji, znajdziesz w naszym poście na blogu.

W konfiguracji ThemeData aplikacji skonfiguruj PageTransitionsTheme tak, aby używać PredictiveBack na Androidzie, a efekt przejścia z zanikaniem z pakietu animacji na innych platformach:

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

Teraz możesz zmienić Navigator.push() na 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'),
),

Użyj FadeThroughTransition, aby zmienić bieżące pytanie

Widżet AnimatedSwitcher udostępnia tylko jeden element Animation w wywołaniu zwrotnym konstruktora. Aby rozwiązać ten problem, pakiet animations udostępnia 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,
          ),
        ),
      ),
    );
  }
}

Używanie OpenContainer

77358e5776eb104c.png

Widżet OpenContainer z pakietu animations zapewnia efekt animacji przekształcenia kontenera, który rozszerza się, tworząc wizualne połączenie między dwoma widżetami.

Widżet zwrócony przez closedBuilder jest wyświetlany początkowo i rozszerza się do widżetu zwróconego przez openBuilder po kliknięciu kontenera lub wywołaniu wywołania zwrotnego openContainer.

Aby połączyć wywołanie zwrotne openContainer z modelem widoku, dodaj nowe przekazanie viewModel do widżetu QuestionCard i zapisz wywołanie zwrotne, które będzie używane do wyświetlania ekranu „Koniec gry”:

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
}

Dodaj nowy widżet 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);
              },
            ),
          ],
        ),
      ),
    );
  }
}

W widżecie QuestionCard zastąp widżet Card widżetem OpenContainer z pakietu animations, dodając 2 nowe pola dla wywołania zwrotnego viewModel i otwartego kontenera:

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. Gratulacje

Udało Ci się dodać efekty animacji do aplikacji we Flutterze i poznać podstawowe komponenty systemu animacji Fluttera. Wiesz już:

  • ImplicitlyAnimatedWidget – jak używać
  • ExplicitlyAnimatedWidget – jak używać
  • Jak zastosować CurvesTweens w animacji
  • Jak korzystać z gotowych widżetów przejść, takich jak AnimatedSwitcher czy PageRouteBuilder
  • Jak używać gotowych efektów animacji z pakietu animations, takich jak FadeThroughTransitionOpenContainer
  • Jak dostosować domyślną animację przejścia, w tym dodać obsługę przewidywanego przejścia wstecz na Androidzie.

3026390ad413769c.gif

Co dalej?

Zapoznaj się z tymi ćwiczeniami z programowania:

Możesz też pobrać przykładową aplikację z animacjami, która prezentuje różne techniki animacji.

Więcej informacji

Więcej materiałów o animacjach znajdziesz na stronie flutter.dev:

Możesz też zapoznać się z tymi artykułami w serwisie Medium:

Dokumentacja