Animacje w technologii Flutter

1. Wprowadzenie

Animacje to świetny sposób na zwiększenie wygody użytkowników aplikacji, przekazanie im ważnych informacji oraz uatrakcyjnienie aplikacji i zwiększenie przyjemności z jej używania.

Omówienie ramowego systemu animacji Fluttera

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

  • Animacje domyślne to wstępnie utworzone efekty animacji, które automatycznie odtwarzają całą animację. Gdy wartość docelowa animacji ulegnie zmianie, animacja zostanie uruchomiona od bieżącej wartości do wartości docelowej i wyświetli każdą wartość pośrednią, aby animacja przebiegała płynnie. Przykłady animacji domyślnych to AnimatedSize, AnimatedScaleAnimatedPositioned.
  • Animacje jednoznaczne to również gotowe efekty animacji, ale do ich działania wymagany jest obiekt Animation. Przykłady: SizeTransition, ScaleTransition lub PositionedTransition.
  • Animation to klasa reprezentująca uruchomioną lub zatrzymaną animację. Składa się ona z wartości, która reprezentuje wartość docelową, do której animacja jest uruchomiona, oraz stanu, który reprezentuje bieżącą wartość wyświetlaną przez animację na ekranie w danym momencie. Jest to podklasa klasy Listenable, która powiadamia swoich słuchaczy o zmianie stanu podczas odtwarzania animacji.
  • AnimationController to sposób na tworzenie animacji i sterowanie jej stanem. Metody takie jak forward(), reset(), stop()repeat() mogą służyć do sterowania animacją bez konieczności definiowania wyświetlanego efektu animacji, takiego jak skala, rozmiar czy położenie.
  • Tweeny służą do interpolowania wartości między wartością początkową a końcową i mogą reprezentować dowolny typ, np. podwójną, Offset lub Color.
  • Krzywe służą do dostosowywania szybkości zmiany parametru w czasie. Podczas odtwarzania animacji często stosuje się krzywą wykładniczą, aby przyspieszyć lub zwolnić tempo zmian na początku lub na końcu animacji. 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 tym ćwiczeniu z programowania utworzysz grę z quizem jednokrotnego wyboru, która będzie zawierać różne efekty i techniki animacji.

3026390ad413769c.gif

Zobaczysz, jak:

  • Tworzenie widżetu, który animuje jego rozmiar i kolor
  • Tworzenie efektu odwracania karty 3D
  • Używanie efektownych gotowych efektów animacji z pakietu animacji
  • Dodanie obsługi gestu przewidywanego przejścia wstecz w najnowszej wersji Androida.

Czego się nauczysz

Z tego ćwiczenia dowiesz się:

  • Jak używać efektów animowanych w ramach, aby uzyskać świetnie wyglądające animacje bez konieczności pisania dużej ilości kodu.
  • Jak używać wyraźnie animowanych efektów do konfigurowania własnych efektów za pomocą gotowych animowanych widżetów, takich jak AnimatedSwitcher lub AnimationController.
  • Jak za pomocą AnimationController zdefiniować własny widżet wyświetlający efekt 3D.
  • Jak za pomocą animations pakietu wyświetlać efektowne animacje przy minimalnej konfiguracji.

Czego potrzebujesz

  • Pakiet Flutter SDK
  • IDE, takie jak VSCode, Android Studio lub IntelliJ

2. Konfigurowanie środowiska programistycznego Flutter

Do wykonania tego ćwiczenia potrzebne są 2 programy: Flutter SDKedytor.

Możesz uruchomić laboratorium kodu na dowolnym z tych urządzeń:

  • Fizyczne urządzenie Android (zalecane do implementacji przewidywanego powrotu w kroku 7) lub iOS podłączone do komputera i ustawione w trybie programisty.
  • Symulator iOS (wymaga zainstalowania narzędzi Xcode).
  • Emulator Androida (wymaga skonfigurowania w Android Studio).
  • przeglądarkę (do debugowania wymagana jest przeglądarka Chrome);
  • Komputer stacjonarny z systemem Windows, Linux lub macOS. Musisz tworzyć aplikację na platformie, na której planujesz ją wdrożyć. Jeśli więc chcesz tworzyć aplikacje na komputery z systemem Windows, musisz to robić w Windowsie, aby mieć dostęp do odpowiedniego łańcucha kompilacji. Istnieją wymagania dotyczące poszczególnych systemów operacyjnych, które omówiono szczegółowo na stronie docs.flutter.dev/desktop.

Weryfikacja instalacji

Aby sprawdzić, czy pakiet SDK Flutter jest prawidłowo skonfigurowany i czy masz zainstalowaną co najmniej jedną z wymienionych powyżej 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 wyjściowej

Pobieranie aplikacji startowej

Użyj git, aby skopiować aplikację startową z repozytorium flutter/samples na 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

Aplikację możesz też uruchomić i uruchomić debugowanie w wybranym środowisku IDE. Więcej informacji znajdziesz w oficjalnej dokumentacji Fluttera.

Omówienie kodu

Aplikacja startowa to gra z pytaniami wielokrotnego wyboru, która składa się z 2 ekranów zgodnie z wzorcem projektowania model-view-view-model (MVVM). Komponent QuestionScreen (widok) korzysta z klasy QuizViewModel (widok-model), 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łości aplikacji.
  • question_screen.dart – wyświetla interfejs użytkownika gry typu quiz.
  • view_model.dart – przechowuje stan i logikę gry w quiz, wyświetlaną przez QuestionScreen

fbb1e1f7b6c91e21.png

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

4. Używanie efektów animacji domyślnych

Animatory niejawne są świetnym rozwiązaniem w wielu sytuacjach, ponieważ nie wymagają specjalnej konfiguracji. W tej sekcji zaktualizujesz widżet StatusBar, aby wyświetlał animowaną tablicę wyników. Aby znaleźć typowe efekty animacji niejawnej, przejrzyj dokumentację interfejsu API ImplicitlyAnimatedWidget.

206dd8d9c1fae95.gif

Tworzenie nieanimowanego widżetu 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 jako element potomny widżetu StatusBar, zastępując nim widżety Text, które wcześniej wyświetlały wynik i łączną liczbę pytań. Twój edytor powinien automatycznie dodać wymagane 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 dla każdego pytania. Gdy odpowiedź na pytanie jest poprawna, kolejna gwiazda zapala się natychmiast bez animacji. W następnych krokach poinformujesz użytkownika o zmianie jego 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 wartości scale0.5 na 1.0, gdy gwiazda 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(                                      // 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.

Gdy użytkownik udzieli prawidłowej odpowiedzi na pytanie, widget AnimatedStar zmieni swój rozmiar za pomocą ukrytej animacji. Element Icon color nie jest tutaj animowany, tylko element scale, który jest animowany przez widżet AnimatedScale.

84aec4776e70b870.gif

Używanie funkcji Tween do interpolowania między 2 wartościami

Zwróć uwagę, że kolor widżetu AnimatedStar zmienia się natychmiast po zmianie wartości pola isActive na „prawda”.

Aby uzyskać efekt animowanego koloru, możesz użyć widgetu AnimatedContainer (który jest inną podklasą elementu ImplicitlyAnimatedWidget), ponieważ może automatycznie animować wszystkie swoje atrybuty, w tym kolor. 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. Nie ma jednak domyślnej implementacji ikony gwiazdki w klasie AnimatedIcons.

Zamiast tego użyjemy innej podklasy klasy ImplicitlyAnimatedWidget o nazwie TweenAnimationBuilder, która przyjmuje jako parametr wartość Tween. Przejście jest klasą, która przyjmuje 2 wartości (beginend) i oblicza wartości pośrednie, aby animacja mogła je wyświetlić. W tym przykładzie użyjemy ColorTween, który spełnia wymagania interfejsu Tween<Color>, niezbędne do tworzenia efektu animacji.

Wybierz widżet Icon i użyj szybkiej czynności „Zakończ za pomocą Buildera” w swoim IDE, a potem 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,                                      // Modify from here...
          );
        },                                                     // To here.
      ),
    );
  }
}

Teraz ponownie załaduj aplikację, aby zobaczyć nową animację.

8b0911f4af299a60.gif

Zwróć uwagę, że wartość end w naszym ColorTween zmienia się w zależności od wartości parametru isActive. Dzieje się tak, ponieważ TweenAnimationBuilder uruchamia animację ponownie, gdy wartość Tween.end ulegnie zmianie. W takim przypadku nowa animacja będzie działać od bieżącej wartości animacji do nowej wartości końcowej, co pozwoli Ci zmienić kolor w dowolnym momencie (nawet podczas odtwarzania animacji) i wyświetlić płynny efekt animacji z poprawnymi wartościami pośrednimi.

Stosowanie krzywej

Oba te efekty animacji działają z równą prędkością, ale animacje są często bardziej interesujące i przekazujące więcej informacji, gdy są przyspieszane lub zwalniane.

Curve stosuje funkcję wygaszania, która określa tempo zmian parametru w czasie. Flutter zawiera wstępnie utworzone zbiory krzywych wygaszania w klasie Curves, np. easeIn lub easeOut.

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

Te diagramy (dostępne na Curvesstronie dokumentacji interfejsu API) wskazują, jak działają krzywe. Krzywe przekształcają wartość wejściową z zakresu od 0,0 do 1,0 (wyświetlana na osi X) na wartość wyjściową z zakresu od 0,0 do 1,0 (wyświetlana na osi Y). Te schematy zawierają też podgląd tego, jak wyglądają różne efekty animacji, gdy używasz krzywej wygaszania.

Utwórz w animowanym widżecie nowe pole 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 wyolbrzymiony efekt sprężystości, który zaczyna się od sprężystego ruchu i zrównoważa się pod koniec.

8f84142bff312373.gif

Aby zobaczyć tę krzywą zastosowaną do AnimatedSizeTweenAnimationBuilder, ponownie załaduj aplikację.

206dd8d9c1fae95.gif

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

Aby debugować dowolny efekt animacji, możesz spowolnić wszystkie animacje w aplikacji za pomocą narzędzia Flutter DevTools.

Aby otworzyć DevTools, upewnij się, że aplikacja działa w trybie debugowania, i otwórz Widget Inspector, klikając go na pasku narzędzi Debugowanie w VSCode lub klikając przycisk Otwórz Flutter DevToolsoknie narzędzia Debugowanie w IntelliJ / Android Studio.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

Po otwarciu inspektora widżetu kliknij na pasku narzędzi przycisk Wyłącz animacje.

adea0a16d01127ad.png

5. Używanie efektów animacji dla dorosłych

Podobnie jak animacje domyślne, animacje jawne to wstępnie utworzone efekty animacji, ale zamiast wartości docelowej przyjmują jako parametr obiekt Animation. Jest to przydatne w sytuacjach, gdy animacja jest już zdefiniowana przez przejście nawigacyjne, na przykład AnimatedSwitcher lub AnimationController.

Używanie wyraźnego efektu animacji

Aby rozpocząć pracę z wyraźnym efektem animacji, owiń widżet Card elementem 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
    );
  }
}

Domyślnie funkcja AnimatedSwitcher używa efektu przejścia, ale możesz ją zastąpić za pomocą parametru transitionBuilder. Kreator przejścia udostępnia widżet podrzędny przekazany do AnimatedSwitcher oraz obiekt Animation. To świetna okazja do użycia wyraźnej animacji.

W tym ćwiczeniu z poziomu kodu użyjemy pierwszej animacji jawnej SlideTransition, która przyjmuje parametr Animation<Offset> określający przesunięcie początkowe i końcowe, między którymi będą się przemieszczać widżety przychodzące i wychodzące.

Przejścia mają funkcję pomocniczą animate(), która zamienia dowolne Animation na inne Animation z zastosowaniem przejścia. Oznacza to, że funkcja Tween<Offset> może służyć do konwertowania wartości Animation<double> podanej przez AnimatedSwitcher na wartość Animation<Offset>, która zostanie przekazana widżetowi 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,
          ),
        ),
      ),
    );
  }
}

Pamiętaj, że w tym przypadku funkcja Tween.animate stosuje funkcję Curve do funkcji Animation, a następnie przekształca ją z funkcji Tween<double> o zakresie od 0,0 do 1,0 w funkcję Tween<Offset>, która na osi x przechodzi od -0,1 do 0,0.

Klasa Animation ma też funkcję drive(), która przyjmuje dowolną wartość Tween (lub Animatable) i konwertuje ją na nową wartość Animation. Dzięki temu możesz „łańcuchowo” łączyć animacje, co pozwoli Ci uzyskać bardziej zwięzły kod:

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ą stosowania animacji szczegółowych jest to, że można je łatwo łączyć. Dodaj kolejną animację, FadeTransition, która używa tej samej wygiętej animacji, otaczając 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
  },

Dostosowywanie kreatora układu

Możesz zauważyć niewielki problem z AnimationSwitcher. Gdy karta z pytaniami przełączy się na nowe pytanie, umieści je na środku dostępnej przestrzeni, a gdy animacja się zakończy, widget zostanie przypięty u góry ekranu. Powoduje to niepłynną animację, ponieważ końcowa pozycja karty z pytaniem nie jest taka sama jak podczas odtwarzania animacji.

d77de181bdde58f7.gif

Aby to naprawić, AnimatedSwitcher ma też parametr layoutBuilder, który można użyć do zdefiniowania układu. Użyj tej funkcji, aby skonfigurować kreatora układu w celu wyrównania karty do górnej części 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 zamiast Alignment.center używa on funkcji Alignment.topCenter.

Podsumowanie

  • Wyraźne animacje to efekty animacji, które przyjmują obiekt Animation (w przeciwieństwie do elementów ImplicitlyAnimatedWidgets, które przyjmują wartość docelową i czas trwania).
  • Klasa Animation reprezentuje uruchomioną animację, ale nie definiuje konkretnego efektu.
  • Aby zastosować w animacji Tweeny i krzywe (za pomocą CurveTween), użyj funkcji Tween().animate lub Animation.drive().
  • Użyj parametru layoutBuilder obiektu AnimatedSwitcher, aby dostosować sposób rozmieszczania elementów podrzędnych.

6. Sterowanie stanem animacji

Do tej pory każda animacja była uruchamiana automatycznie przez framework. Animatory niejawne działają automatycznie, a animatory jawne wymagają prawidłowego działania animacji. Z tej sekcji dowiesz się, jak tworzyć własne obiekty Animation za pomocą AnimationController i jak łączyć Tweeny za pomocą TweenSequence.

Uruchamianie animacji za pomocą AnimationController

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

  1. Tworzenie komponentu StatefulWidget
  2. Użyj klasy pomocniczej SingleTickerProviderStateMixin w klasie stanu, aby przekazać obiekt Ticker do klasy AnimationController.
  3. Inicjuj AnimationController w metodzie initState cyklu życia, przekazując bieżący obiekt stanu do parametru vsync (TickerProvider).
  4. Upewnij się, że widżet jest ponownie tworzony, gdy AnimationController powiadamia swoich słuchaczy. Możesz to zrobić za pomocą AnimatedBuilder lub wywołując listen() i setState ręcznie.

Utwórz nowy plik flip_effect.dart i wklej 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 obiekt AnimationController i uruchamia ponownie animację, gdy framework wywołuje metodę didUpdateWidget, aby powiadomić o zmianie konfiguracji widżetu i możliwym pojawieniu się nowego widżetu podrzędnego.

Obiekt AnimatedBuilder dba o to, aby drzewo widżetów było odtwarzane za każdym razem, gdy AnimationController powiadamia swoich słuchaczy. Widżet Transform służy do stosowania efektu obrotu 3D, aby symulować odwracanie karty.

Aby użyć tego widżetu, owiń każdą kartę odpowiedzi widżetem CardFlipEffect. Pamiętaj, aby dodać key do widżetu karty:

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 ponownie załaduj aplikację, aby zobaczyć, jak karty odpowiedzi odwracają się za pomocą widżetu CardFlipEffect.

5455def725b866f6.gif

Możesz zauważyć, że ta klasa wygląda bardzo podobnie do jawnego efektu animacji. Często warto bezpośrednio rozszerzyć klasę AnimatedWidget, aby zaimplementować własną wersję. Ta klasa musi przechowywać poprzedni widget w stanie, dlatego musi używać klasy StatefulWidget. Więcej informacji o tworzeniu własnych efektów animacji znajdziesz w dokumentacji interfejsu API dotyczącego AnimatedWidget.

Dodawanie opóźnienia za pomocą funkcji 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 kompilacji 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 nową animację, która zastosuje opóźnienie za pomocą TweenSequence. Pamiętaj, że nie używasz żadnych narzędzi z biblioteki dart:async, takich jak Future.delayed. Dzieje się tak, ponieważ opóźnienie jest częścią animacji i nie jest kontrolowane przez widżet, gdy używa on AnimationController. Dzięki temu łatwiej debugować efekt animacji po włączeniu powolnych animacji w Narzędziach deweloperskich, ponieważ używa on tego samego TickerProvidera.

Aby użyć TweenSequence, utwórz 2 elementy TweenSequenceItem, z których jeden zawiera ConstantTween, który utrzymuje animację na poziomie 0 przez czas względny, oraz zwykły element Tween, który przechodzi z poziomu 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>([   // 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
  }

Na koniec w metodzie build zastąp animację kontrolera animacji nową opóźnioną animacją.

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 ponownie załaduj aplikację i obserwuj, jak karty będą się przewracać pojedynczo. Aby spróbować czegoś bardziej wymagającego, zmień perspektywę efektu 3D, który zapewnia widżet Transform.

28b5291de9b3f55f.gif

7. Używanie niestandardowych przejść nawigacyjnych

Do tej pory omawialiśmy dostosowywanie efektów na jednym ekranie, ale animacje można też stosować 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 gotowych efektów animacji udostępnionych przez oficjalny pakiet animations na stronie pub.dev.

Animowanie przejścia nawigacyjnego

Klasa PageRouteBuilder to Route, która umożliwia dostosowywanie animacji przejścia. Pozwala on zastąpić wywołanie 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 wartością PageRouteBuilder. Aby dostosować animację przejścia, gdy użytkownik przechodzi z poziomu HomeScreen do poziomu QuestionScreen, zastąp MaterialPageRoute wartością QuestionScreen. Użyj przejścia ściemnienia (widżetu z wyraźnie animowanym efektem) do płynnego przejścia do nowego ekranu z poprzedniego.

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

Pakiet animacji zawiera gotowe efekty animacji, takie jak przejście przez ekran. Zaimportuj pakiet animacji i zastąp widget FadeTransition widgetem 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'),
),

Dostosowywanie animacji przewidywanego przejścia wstecz

1c0558ffa3b76439.gif

Prognozowane cofnięcie to nowa funkcja Androida, która umożliwia użytkownikowi sprawdzenie, co znajduje się za bieżącą trasą lub aplikacją, zanim przejdzie do następnej czynności. Animacja podglądu jest sterowana przez lokalizację palca użytkownika, gdy przesuwa on palcem po ekranie.

Flutter obsługuje system przewidywania wstecz, włączając tę funkcję na poziomie systemu, gdy nie ma żadnych tras do wyświetlenia na stosie nawigacji, czyli gdy wstecz spowoduje zamknięcie aplikacji. Tą animacją zarządza system, a nie Flutter.

Flutter obsługuje też przewidywane cofanie podczas przechodzenia między ścieżkami w aplikacji Flutter. Specjalny komponent PageTransitionsBuilder o nazwie PredictiveBackPageTransitionsBuilder wykrywa gesty przewidywanego cofania systemu i steruje przejściami między stronami w miarę ich wykonywania.

Wsteczne cofnięcie jest obsługiwane tylko w Androidzie U i nowszych wersjach, ale Flutter płynnie przełączy się na pierwotne zachowanie gestu cofnięcia i ZoomPageTransitionBuilder. Więcej informacji znajdziesz w poście na blogu, w tym w sekcji poświęconej konfigurowaniu tej funkcji w Twojej aplikacji.

W konfiguracji ThemeData swojej aplikacji skonfiguruj PageTransitionsTheme, aby używać funkcji PredictiveBack na Androidzie, oraz efektu przejścia przezroczystego 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),
        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(),
    );
  }
}

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

Zmiana bieżącego pytania za pomocą przejścia z wygaszaniem

Widżet AnimatedSwitcher udostępnia tylko jedną animację w swoim wywołaniu zwrotnym w budżerze. Aby rozwiązać ten problem, pakiet animations udostępnia przełącznik 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,
          ),
        ),
      ),
    );
  }
}

Korzystanie z OpenContainer

77358e5776eb104c.png

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

Początkowo wyświetlany jest widżet zwracany przez funkcję closedBuilder, a po kliknięciu kontenera lub wywołaniu funkcji zwrotnej openContainer wyświetla się widżet zwracany przez funkcję openBuilder.

Aby połączyć funkcję openContainer z modelem widoku, dodaj nowy przekaz do viewModel w widżecie QuestionCard i zapisz funkcję, która będzie używana 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, Ekran po zakończeniu gry:

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 element Card widżetem OpenContainer z pakietu animacji, dodając 2 nowe pola dla viewModel i funkcji wywołania otwierania 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

Gratulacje! Udało Ci się dodać efekty animacji do aplikacji Flutter i poznać główne komponenty systemu animacji Flutter. W szczególności dowiesz się:

  • Jak używać widgetu z animacją niejawną
  • Jak używać widoku animowanego w sposób jawny
  • Jak zastosować krzywe i interpolacje w animacji
  • Jak używać gotowych widżetów przejść, takich jak AnimatedSwitcher czy PageRouteBuilder
  • Jak używać efektów gotowych animacji z pakietu animations, takich jak przejście przez zniknięcie i otwarcie kontenera
  • Jak dostosować domyślną animację przejścia, w tym dodać obsługę funkcji Wstecz na podstawie przewidywania na Androidzie.

3026390ad413769c.gif

Co dalej?

Zapoznaj się z tymi ćwiczeniami z programowania:

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

Więcej informacji

Więcej materiałów na temat animacji znajdziesz na flutter.dev:

Możesz też przeczytać te artykuły na Medium:

Dokumenty referencyjne