Animasi di Flutter

1. Pengantar

Animasi adalah cara yang bagus untuk meningkatkan pengalaman pengguna aplikasi Anda, menyampaikan informasi penting kepada pengguna, dan membuat aplikasi Anda lebih rapi dan menyenangkan untuk digunakan.

Ringkasan framework animasi Flutter

Flutter menampilkan efek animasi dengan mem-build ulang sebagian hierarki widget di setiap frame. Library ini menyediakan efek animasi bawaan dan API lainnya untuk mempermudah pembuatan dan komposisi animasi.

  • Animasi implisit adalah efek animasi bawaan yang menjalankan seluruh animasi secara otomatis. Saat nilai target animasi berubah, nilai tersebut akan menjalankan animasi dari nilai saat ini ke nilai target, dan menampilkan setiap nilai di antaranya sehingga widget akan dianimasikan dengan lancar. Contoh animasi implisit meliputi AnimatedSize, AnimatedScale, dan AnimatedPositioned.
  • Animasi eksplisit juga merupakan efek animasi bawaan, tetapi memerlukan objek Animation agar dapat berfungsi. Contohnya meliputi SizeTransition, ScaleTransition, atau PositionedTransition.
  • Animation adalah class yang mewakili animasi yang berjalan atau berhenti, dan terdiri dari value yang mewakili nilai target yang dijalankan animasi, dan status, yang mewakili nilai saat ini yang ditampilkan animasi di layar pada waktu tertentu. Ini adalah subclass dari Listenable, dan memberi tahu pemrosesnya saat status berubah saat animasi berjalan.
  • AnimationController adalah cara untuk membuat Animasi dan mengontrol statusnya. Metodenya seperti forward(), reset(), stop(), dan repeat() dapat digunakan untuk mengontrol animasi tanpa perlu menentukan efek animasi yang ditampilkan, seperti skala, ukuran, atau posisi.
  • Tween digunakan untuk mengalurkan nilai antara nilai awal dan akhir, serta dapat mewakili jenis apa pun, seperti ganda, Offset, atau Color.
  • Kurva digunakan untuk menyesuaikan laju perubahan parameter dari waktu ke waktu. Saat animasi berjalan, biasanya kurva easing diterapkan untuk membuat laju perubahan lebih cepat atau lebih lambat di awal atau akhir animasi. Kurva menggunakan nilai input antara 0,0 dan 1,0 dan menampilkan nilai output antara 0,0 dan 1,0.

Yang akan Anda bangun

Dalam codelab ini, Anda akan membuat game kuis pilihan ganda yang menampilkan berbagai efek dan teknik animasi.

3026390ad413769c.gif

Anda akan melihat cara...

  • Mem-build widget yang menganimasikan ukuran dan warnanya
  • Membuat efek putaran kartu 3D
  • Menggunakan efek animasi bawaan yang menarik dari paket animasi
  • Menambahkan dukungan gestur kembali prediktif yang tersedia di versi Android terbaru

Yang akan Anda pelajari

Dalam codelab ini, Anda akan mempelajari:

  • Cara menggunakan efek animasi implisit untuk mendapatkan animasi yang terlihat bagus tanpa memerlukan banyak kode.
  • Cara menggunakan efek animasi eksplisit untuk mengonfigurasi efek Anda sendiri menggunakan widget animasi bawaan seperti AnimatedSwitcher atau AnimationController.
  • Cara menggunakan AnimationController untuk menentukan widget Anda sendiri yang menampilkan efek 3D.
  • Cara menggunakan paket animations untuk menampilkan efek animasi yang menarik dengan penyiapan minimal.

Yang Anda butuhkan

  • Flutter SDK
  • IDE, seperti VSCode atau Android Studio / IntelliJ

2. Menyiapkan lingkungan pengembangan Flutter Anda

Anda memerlukan dua software untuk menyelesaikan lab ini — Flutter SDK dan editor.

Anda dapat menjalankan codelab menggunakan salah satu perangkat berikut:

  • Perangkat Android (direkomendasikan untuk menerapkan kembali prediktif di langkah 7) atau iOS fisik yang terhubung ke komputer dan disetel ke mode Developer.
  • Simulator iOS (memerlukan penginstalan alat Xcode).
  • Android Emulator (memerlukan penyiapan di Android Studio).
  • Browser (Chrome diperlukan untuk proses debug).
  • Komputer desktop Windows, Linux, atau macOS. Anda harus melakukan pengembangan di platform tempat Anda berencana men-deploy aplikasi. Jadi, jika ingin mengembangkan aplikasi desktop Windows, Anda harus mengembangkannya di Windows untuk mengakses rantai build yang sesuai. Ada persyaratan spesifik per sistem operasi yang dibahas secara mendetail di docs.flutter.dev/desktop.

Memverifikasi penginstalan

Untuk memverifikasi bahwa Flutter SDK Anda dikonfigurasi dengan benar, dan Anda telah menginstal setidaknya salah satu platform target di atas, gunakan alat 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. Menjalankan aplikasi awal

Mendownload aplikasi awal

Gunakan git untuk meng-clone aplikasi awal dari repositori flutter/samples di GitHub.

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

Atau, Anda dapat mendownload kode sumber sebagai file .zip.

Menjalankan aplikasi

Untuk menjalankan aplikasi, gunakan perintah flutter run dan tentukan perangkat target, seperti android, ios, atau chrome. Untuk mengetahui daftar lengkap platform yang didukung, lihat halaman Platform yang didukung.

$ flutter run -d android

Anda juga dapat menjalankan dan men-debug aplikasi menggunakan IDE pilihan Anda. Lihat dokumentasi Flutter resmi untuk mengetahui informasi selengkapnya.

Menerapkan kode

Aplikasi awal adalah game kuis pilihan ganda yang terdiri dari dua layar yang mengikuti pola desain model-tampilan-tampilan-model, atau MVVM. QuestionScreen (Tampilan) menggunakan class QuizViewModel (Model Tampilan) untuk mengajukan pertanyaan pilihan ganda kepada pengguna dari class QuestionBank (Model).

  • home_screen.dart - Menampilkan layar dengan tombol New Game
  • main.dart - Mengonfigurasi MaterialApp untuk menggunakan Material 3 dan menampilkan layar utama
  • model.dart - Menentukan class inti yang digunakan di seluruh aplikasi
  • question_screen.dart - Menampilkan UI untuk game kuis
  • view_model.dart - Menyimpan status dan logika untuk game kuis, yang ditampilkan oleh QuestionScreen

fbb1e1f7b6c91e21.png

Aplikasi belum mendukung efek animasi apa pun, kecuali untuk transisi tampilan default yang ditampilkan oleh class Navigator Flutter saat pengguna menekan tombol Game Baru.

4. Menggunakan efek animasi implisit

Animasi implisit adalah pilihan yang tepat dalam banyak situasi, karena tidak memerlukan konfigurasi khusus. Di bagian ini, Anda akan memperbarui widget StatusBar agar menampilkan papan skor animasi. Untuk menemukan efek animasi implisit umum, jelajahi dokumentasi API ImplicitlyAnimatedWidget.

206dd8d9c1fae95.gif

Membuat widget papan skor tanpa animasi

Buat file baru, lib/scoreboard.dart dengan kode berikut:

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

Kemudian, tambahkan widget Scoreboard di turunan widget StatusBar, yang menggantikan widget Text yang sebelumnya menampilkan skor dan total jumlah pertanyaan. Editor Anda akan otomatis menambahkan import "scoreboard.dart" yang diperlukan di bagian atas file.

lib/question_screen.dart

class StatusBar extends StatelessWidget {
  final QuizViewModel viewModel;

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

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

Widget ini menampilkan ikon bintang untuk setiap pertanyaan. Saat pertanyaan dijawab dengan benar, bintang lain akan langsung menyala tanpa animasi apa pun. Pada langkah-langkah berikut, Anda akan membantu memberi tahu pengguna bahwa skor mereka berubah dengan menganimasikan ukuran dan warnanya.

Menggunakan efek animasi implisit

Buat widget baru bernama AnimatedStar yang menggunakan widget AnimatedScale untuk mengubah jumlah scale dari 0.5 menjadi 1.0 saat bintang menjadi aktif:

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.

Sekarang, saat pengguna menjawab pertanyaan dengan benar, widget AnimatedStar akan memperbarui ukurannya menggunakan animasi implisit. color Icon tidak dianimasikan di sini, hanya scale, yang dilakukan oleh widget AnimatedScale.

84aec4776e70b870.gif

Menggunakan Tween untuk melakukan interpolasi antara dua nilai

Perhatikan bahwa warna widget AnimatedStar langsung berubah setelah kolom isActive berubah menjadi benar.

Untuk mendapatkan efek warna animasi, Anda dapat mencoba menggunakan widget AnimatedContainer (yang merupakan subclass lain dari ImplicitlyAnimatedWidget), karena widget ini dapat menganimasikan semua atributnya secara otomatis, termasuk warna. Sayangnya, widget kita harus menampilkan ikon, bukan penampung.

Anda juga dapat mencoba AnimatedIcon, yang menerapkan efek transisi di antara bentuk ikon. Namun, tidak ada implementasi default ikon bintang di class AnimatedIcons.

Sebagai gantinya, kita akan menggunakan subclass ImplicitlyAnimatedWidget lain yang disebut TweenAnimationBuilder, yang menggunakan Tween sebagai parameter. Tween adalah class yang mengambil dua nilai (begin dan end) dan menghitung nilai di antaranya, sehingga animasi dapat menampilkannya. Dalam contoh ini, kita akan menggunakan ColorTween, yang memenuhi antarmuka Tween<Color> yang diperlukan untuk membuat efek animasi.

Pilih widget Icon dan gunakan tindakan cepat "Gabungkan dengan Builder" di IDE Anda, ubah namanya menjadi TweenAnimationBuilder. Kemudian, berikan durasi dan 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.
      ),
    );
  }
}

Sekarang, lakukan hot reload aplikasi untuk melihat animasi baru.

8b0911f4af299a60.gif

Perhatikan bahwa nilai end dari ColorTween berubah berdasarkan nilai parameter isActive. Hal ini karena TweenAnimationBuilder menjalankan ulang animasinya setiap kali nilai Tween.end berubah. Jika hal ini terjadi, animasi baru akan berjalan dari nilai animasi saat ini ke nilai akhir baru, yang memungkinkan Anda mengubah warna kapan saja (bahkan saat animasi berjalan) dan menampilkan efek animasi yang halus dengan nilai perantara yang benar.

Menerapkan Kurva

Kedua efek animasi ini berjalan dengan kecepatan konstan, tetapi animasi sering kali lebih menarik dan informatif secara visual saat dipercepat atau diperlambat.

Curve menerapkan fungsi easing, yang menentukan laju perubahan parameter dari waktu ke waktu. Flutter dilengkapi dengan kumpulan kurva easing bawaan di class Curves, seperti easeIn atau easeOut.

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

Diagram ini (tersedia di halaman dokumentasi Curves API) memberikan petunjuk tentang cara kerja kurva. Kurva mengonversi nilai input antara 0,0 dan 1,0 (ditampilkan pada sumbu x) menjadi nilai output antara 0,0 dan 1,0 (ditampilkan pada sumbu y). Diagram ini juga menampilkan pratinjau tampilan berbagai efek animasi saat menggunakan kurva easing.

Buat kolom baru di AnimatedStar bernama _curve dan teruskan sebagai parameter ke widget AnimatedScale dan TweenAnimationBuilder.

lib/scoreboard.dart

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

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

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

Dalam contoh ini, kurva elasticOut memberikan efek pegas yang berlebihan yang dimulai dengan gerakan pegas dan seimbang di bagian akhir.

8f84142bff312373.gif

Lakukan hot reload pada aplikasi untuk melihat kurva ini diterapkan ke AnimatedSize dan TweenAnimationBuilder.

206dd8d9c1fae95.gif

Menggunakan DevTools untuk mengaktifkan animasi lambat

Untuk men-debug efek animasi apa pun, Flutter DevTools menyediakan cara untuk memperlambat semua animasi di aplikasi, sehingga Anda dapat melihat animasi dengan lebih jelas.

Untuk membuka DevTools, pastikan aplikasi berjalan dalam mode debug, dan buka Widget Inspector dengan memilihnya di Debug toolbar di VSCode atau dengan memilih tombol Open Flutter DevTools di Debug tool window di IntelliJ / Android Studio.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

Setelah widget inspector terbuka, klik tombol Slow animations di toolbar.

adea0a16d01127ad.png

5. Menggunakan efek animasi eksplisit

Seperti animasi implisit, animasi eksplisit adalah efek animasi bawaan, tetapi bukan mengambil nilai target, animasi ini mengambil objek Animation sebagai parameter. Hal ini membuatnya berguna dalam situasi ketika animasi sudah ditentukan oleh transisi navigasi, AnimatedSwitcher, atau AnimationController, misalnya.

Menggunakan efek animasi eksplisit

Untuk memulai efek animasi eksplisit, gabungkan widget Card dengan 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 menggunakan efek cross-fade secara default, tetapi Anda dapat menggantinya menggunakan parameter transitionBuilder. Builder transisi menyediakan widget turunan yang diteruskan ke AnimatedSwitcher, dan objek Animation. Ini adalah kesempatan bagus untuk menggunakan animasi eksplisit.

Untuk codelab ini, animasi eksplisit pertama yang akan kita gunakan adalah SlideTransition, yang menggunakan Animation<Offset> yang menentukan offset awal dan akhir yang akan dilalui widget masuk dan keluar.

Tween memiliki fungsi helper, animate(), yang mengonversi Animation menjadi Animation lain dengan tween yang diterapkan. Ini berarti Tween<Offset> dapat digunakan untuk mengonversi Animation<double> yang disediakan oleh AnimatedSwitcher menjadi Animation<Offset>, yang akan disediakan ke widget SlideTransition.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

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

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

Perhatikan bahwa ini menggunakan Tween.animate untuk menerapkan Curve ke Animation, lalu mengonversinya dari Tween<double> yang berkisar dari 0,0 hingga 1,0, menjadi Tween<Offset> yang bertransisi dari -0,1 ke 0,0 pada sumbu x.

Atau, class Animasi memiliki fungsi drive() yang menggunakan Tween (atau Animatable) dan mengonversinya menjadi Animation baru. Hal ini memungkinkan tween "dikaitkan", sehingga kode yang dihasilkan lebih ringkas:

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

Keuntungan lain dari penggunaan animasi eksplisit adalah animasi tersebut dapat dengan mudah disusun bersama. Tambahkan animasi eksplisit lainnya, FadeTransition yang menggunakan animasi melengkung yang sama dengan menggabungkan 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
  },

Menyesuaikan layoutBuilder

Anda mungkin melihat masalah kecil pada AnimationSwitcher. Saat beralih ke pertanyaan baru, QuestionCard akan menatanya di tengah ruang yang tersedia saat animasi berjalan, tetapi saat animasi dihentikan, widget akan ditarik ke bagian atas layar. Hal ini menyebabkan animasi yang tidak lancar karena posisi akhir kartu pertanyaan tidak cocok dengan posisi saat animasi berjalan.

d77de181bdde58f7.gif

Untuk memperbaikinya, AnimatedSwitcher juga memiliki parameter layoutBuilder, yang dapat digunakan untuk menentukan tata letak. Gunakan fungsi ini untuk mengonfigurasi builder tata letak agar menyelaraskan kartu ke bagian atas layar:

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

Kode ini adalah versi modifikasi dari defaultLayoutBuilder dari class AnimatedSwitcher, tetapi menggunakan Alignment.topCenter, bukan Alignment.center.

Ringkasan

  • Animasi eksplisit adalah efek animasi yang menggunakan objek Animasi (berbeda dengan ImplicitlyAnimatedWidgets, yang menggunakan nilai dan durasi target)
  • Class Animation mewakili animasi yang sedang berjalan, tetapi tidak menentukan efek tertentu.
  • Gunakan Tween().animate atau Animation.drive() untuk menerapkan Tween dan Kurva (menggunakan CurveTween) ke animasi.
  • Gunakan parameter layoutBuilder AnimatedSwitcher untuk menyesuaikan cara menata letak turunannya.

6. Mengontrol status animasi

Sejauh ini, setiap animasi telah dijalankan secara otomatis oleh framework. Animasi implisit berjalan secara otomatis, dan efek animasi eksplisit memerlukan Animasi agar berfungsi dengan benar. Di bagian ini, Anda akan mempelajari cara membuat objek Animation Anda sendiri menggunakan AnimationController, dan menggunakan TweenSequence untuk menggabungkan Tween.

Menjalankan animasi menggunakan AnimationController

Untuk membuat animasi menggunakan AnimationController, Anda harus mengikuti langkah-langkah berikut:

  1. Membuat StatefulWidget
  2. Gunakan mixin SingleTickerProviderStateMixin di class State untuk menyediakan Ticker ke AnimationController
  3. Lakukan inisialisasi AnimationController dalam metode siklus proses initState, yang menyediakan objek Status saat ini ke parameter vsync (TickerProvider).
  4. Pastikan widget Anda di-build ulang setiap kali AnimationController memberi tahu pemrosesnya, baik dengan menggunakan AnimatedBuilder atau dengan memanggil listen() dan setState secara manual.

Buat file baru, flip_effect.dart, lalu salin dan tempel kode berikut:

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

Class ini menyiapkan AnimationController dan menjalankan ulang animasi setiap kali framework memanggil didUpdateWidget untuk memberi tahu bahwa konfigurasi widget telah berubah, dan mungkin ada widget turunan baru.

AnimatedBuilder memastikan bahwa hierarki widget dibuat ulang setiap kali AnimationController memberi tahu pemrosesnya, dan widget Transform digunakan untuk menerapkan efek rotasi 3D guna menyimulasikan kartu yang dibalik.

Untuk menggunakan widget ini, gabungkan setiap kartu jawaban dengan widget CardFlipEffect. Pastikan untuk memberikan key ke widget Kartu:

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

Sekarang, hot reload aplikasi untuk melihat kartu jawaban dibalik menggunakan widget CardFlipEffect.

5455def725b866f6.gif

Anda mungkin melihat bahwa class ini terlihat sangat mirip dengan efek animasi eksplisit. Bahkan, sebaiknya Anda memperluas class AnimatedWidget secara langsung untuk menerapkan versi Anda sendiri. Sayangnya, karena class ini perlu menyimpan widget sebelumnya dalam Statusnya, class ini perlu menggunakan StatefulWidget. Untuk mempelajari lebih lanjut cara membuat efek animasi eksplisit Anda sendiri, lihat dokumentasi API untuk AnimatedWidget.

Menambahkan penundaan menggunakan TweenSequence

Di bagian ini, Anda akan menambahkan penundaan ke widget CardFlipEffect sehingga setiap kartu dibalik satu per satu. Untuk memulai, tambahkan kolom baru bernama 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();
}

Kemudian, tambahkan delayAmount ke metode build 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]),

Kemudian, di _CardFlipEffectState, buat Animasi baru yang menerapkan penundaan menggunakan TweenSequence. Perhatikan bahwa ini tidak menggunakan utilitas apa pun dari library dart:async, seperti Future.delayed. Hal ini karena penundaan adalah bagian dari animasi dan bukan sesuatu yang dikontrol secara eksplisit oleh widget saat menggunakan AnimationController. Hal ini membuat efek animasi lebih mudah di-debug saat mengaktifkan animasi lambat di DevTools, karena menggunakan TickerProvider yang sama.

Untuk menggunakan TweenSequence, buat dua TweenSequenceItem, satu berisi ConstantTween yang mempertahankan animasi pada 0 selama durasi relatif dan Tween reguler yang beralih dari 0.0 ke 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
  }

Terakhir, ganti animasi AnimationController dengan animasi tertunda baru dalam metode 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,
  );
}

Sekarang, lakukan hot reload pada aplikasi dan lihat kartu berbalik satu per satu. Sebagai tantangan, coba bereksperimen dengan mengubah perspektif efek 3D yang disediakan oleh widget Transform.

28b5291de9b3f55f.gif

7. Menggunakan transisi navigasi kustom

Sejauh ini, kita telah melihat cara menyesuaikan efek di satu layar, tetapi cara lain untuk menggunakan animasi adalah menggunakannya untuk melakukan transisi antarlayar. Di bagian ini, Anda akan mempelajari cara menerapkan efek animasi ke transisi layar menggunakan efek animasi bawaan dan efek animasi bawaan yang menarik yang disediakan oleh paket animasi resmi di pub.dev

Menganimasikan transisi navigasi

Class PageRouteBuilder adalah Route yang memungkinkan Anda menyesuaikan animasi transisi. Hal ini memungkinkan Anda mengganti callback transitionBuilder-nya, yang menyediakan dua objek Animasi, yang mewakili animasi masuk dan keluar yang dijalankan oleh Navigator.

Untuk menyesuaikan animasi transisi, ganti MaterialPageRoute dengan PageRouteBuilder dan untuk menyesuaikan animasi transisi saat pengguna membuka dari HomeScreen ke QuestionScreen. Gunakan FadeTransition (widget yang dianimasikan secara eksplisit) untuk membuat layar baru memudar di atas layar sebelumnya.

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

Paket animasi menyediakan efek animasi bawaan yang menarik, seperti FadeThroughTransition. Impor paket animasi dan ganti FadeTransition dengan widget FadeThroughTransition:

lib/home_screen.dart

import 'package;animations/animations.dart';

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

Menyesuaikan animasi kembali prediktif

1c0558ffa3b76439.gif

Kembali prediktif adalah fitur Android baru yang memungkinkan pengguna melihat di balik rute atau aplikasi saat ini untuk melihat apa yang ada di baliknya sebelum menavigasi. Animasi peek didorong oleh lokasi jari pengguna saat mereka menarik kembali di layar.

Flutter mendukung kembali prediktif sistem dengan mengaktifkan fitur di tingkat sistem saat Flutter tidak memiliki rute untuk muncul di stack navigasinya, atau dengan kata lain, saat kembali akan keluar dari aplikasi. Animasi ini ditangani oleh sistem, bukan oleh Flutter itu sendiri.

Flutter juga mendukung kembali prediktif saat berpindah antar-rute dalam aplikasi Flutter. PageTransitionsBuilder khusus yang disebut PredictiveBackPageTransitionsBuilder memproses gestur kembali prediktif sistem dan mendorong transisi halamannya dengan progres gestur.

Kembali prediktif hanya didukung di Android U dan yang lebih baru, tetapi Flutter akan kembali ke perilaku gestur kembali asli dan ZoomPageTransitionBuilder dengan lancar. Lihat postingan blog kami untuk mengetahui informasi selengkapnya, termasuk bagian tentang cara menyiapkannya di aplikasi Anda sendiri.

Dalam konfigurasi ThemeData untuk aplikasi Anda, konfigurasikan PageTransitionsTheme untuk menggunakan PredictiveBack di Android, dan efek transisi memudar dari paket animasi di platform lain:

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

Sekarang Anda dapat mengubah panggilan balik Navigator.push() ke 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'),
),

Menggunakan FadeThroughTransition untuk mengubah pertanyaan saat ini

Widget AnimatedSwitcher hanya menyediakan satu Animasi dalam callback builder-nya. Untuk mengatasi hal ini, paket animations menyediakan 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,
          ),
        ),
      ),
    );
  }
}

Menggunakan OpenContainer

77358e5776eb104c.png

Widget OpenContainer dari paket animations memberikan efek animasi transformasi penampung yang diperluas untuk membuat koneksi visual antara dua widget.

Widget yang ditampilkan oleh closedBuilder ditampilkan pada awalnya, dan diperluas ke widget yang ditampilkan oleh openBuilder saat penampung diketuk atau saat callback openContainer dipanggil.

Untuk menghubungkan callback openContainer ke model tampilan, tambahkan kartu baru yang meneruskan viewModel ke widget QuestionCard dan simpan callback yang akan digunakan untuk menampilkan layar "Game Over":

lib/question_screen.dart

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

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

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

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

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

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

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

Di widget QuestionCard, ganti Kartu dengan widget OpenContainer dari paket animasi, dengan menambahkan dua kolom baru untuk viewModel dan callback penampung terbuka:

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

Selamat, Anda telah berhasil menambahkan efek animasi ke aplikasi Flutter, dan mempelajari komponen inti sistem animasi Flutter. Secara khusus, Anda telah mempelajari:

  • Cara menggunakan ImplicitlyAnimatedWidget
  • Cara menggunakan ExplicitlyAnimatedWidget
  • Cara menerapkan Kurva dan Tween ke animasi
  • Cara menggunakan widget transisi bawaan seperti AnimatedSwitcher atau PageRouteBuilder
  • Cara menggunakan efek animasi bawaan yang menarik dari paket animations, seperti FadeThroughTransition dan OpenContainer
  • Cara menyesuaikan animasi transisi default, termasuk menambahkan dukungan untuk Kembali Prediktif di Android.

3026390ad413769c.gif

Apa selanjutnya?

Lihat beberapa codelab ini:

Atau download aplikasi contoh animasi, yang menampilkan berbagai teknik animasi

Bacaan lebih lanjut

Anda dapat menemukan lebih banyak referensi animasi di flutter.dev:

Atau baca artikel ini di Medium:

Dokumen referensi