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 sempurna dan menyenangkan untuk digunakan.

Ringkasan framework animasi Flutter

Flutter menampilkan efek animasi dengan membangun kembali sebagian pohon widget di setiap frame. Library ini menyediakan efek animasi bawaan dan API lainnya untuk mempermudah pembuatan dan penyusunan animasi.

  • Animasi implisit adalah efek animasi bawaan yang menjalankan seluruh animasi secara otomatis. Saat nilai target animasi berubah, animasi akan berjalan dari nilai saat ini ke nilai target, dan menampilkan setiap nilai di antaranya sehingga widget dianimasikan dengan lancar. Contoh animasi implisit mencakup 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 merepresentasikan animasi yang sedang berjalan atau dihentikan, dan terdiri dari value yang merepresentasikan nilai target yang sedang dijalankan animasi, dan status, yang merepresentasikan nilai saat ini yang ditampilkan animasi di layar pada waktu tertentu. Class ini adalah subclass dari Listenable, dan memberi tahu pendengarnya saat status berubah saat animasi sedang 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 menginterpolasi nilai antara nilai awal dan akhir, dan dapat merepresentasikan 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 mengambil nilai input antara 0,0 dan 1,0 serta menampilkan nilai output antara 0,0 dan 1,0.

Yang akan Anda build

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

3026390ad413769c.gif

Anda akan melihat cara...

  • Membangun widget yang menganimasikan ukuran dan warnanya
  • Membangun efek balik kartu 3D
  • Menggunakan efek animasi bawaan yang menarik dari paket animasi
  • Menambahkan dukungan gestur kembali prediktif yang tersedia di Android versi terbaru

Yang akan Anda pelajari

Dalam codelab ini, Anda akan mempelajari:

  • Cara menggunakan efek animasi implisit untuk menghasilkan 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 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 fisik (direkomendasikan untuk menerapkan kembali prediktif pada langkah 7) atau iOS yang terhubung ke komputer dan disetel ke mode Developer.
  • Simulator iOS (perlu menginstal 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 Anda

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-view-view-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 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 sehingga menampilkan papan skor animasi. Untuk menemukan efek animasi implisit umum, jelajahi dokumentasi API ImplicitlyAnimatedWidget.

206dd8d9c1fae95.gif

Membuat widget papan skor yang tidak beranimasi

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, 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. Pada langkah berikutnya, Anda akan membantu memberi tahu pengguna bahwa skornya 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(isActive: score > i),                 // Edit this line.
        ],
      ),
    );
  }
}

class AnimatedStar extends StatelessWidget {                   // Add from here...
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

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

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: Icon(
        Icons.star,
        size: 50,
        color: isActive ? _activatedColor : _deactivatedColor,
      ),
    );
  }
}                                                              // To here.

Sekarang, saat pengguna menjawab pertanyaan dengan benar, widget AnimatedStar akan memperbarui ukurannya menggunakan animasi implisit. Icon's color 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 akan langsung berubah setelah kolom isActive berubah menjadi benar (true).

Untuk mendapatkan efek warna animasi, Anda dapat mencoba menggunakan widget AnimatedContainer (yang merupakan subkelas ImplicitlyAnimatedWidget lainnya), karena widget ini dapat menganimasikan semua atributnya secara otomatis, termasuk warna. Sayangnya, widget kami perlu 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 yang diperlukan untuk membuat efek animasi.

Pilih widget Icon dan gunakan tindakan cepat "Wrap with Builder" di IDE Anda, lalu 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);     // And modify this line.
        },
      ),
    );
  }
}

Sekarang, lakukan hot reload pada aplikasi untuk melihat animasi baru.

8b0911f4af299a60.gif

Perhatikan bahwa nilai end dari ColorTween kita 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 sedang berjalan) dan menampilkan efek animasi yang lancar dengan nilai di antaranya yang benar.

Menerapkan Kurva

Kedua efek animasi ini berjalan pada kecepatan yang 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 API Curves) 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 menyeimbangkan diri 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 Anda, sehingga Anda dapat melihat animasi dengan lebih jelas.

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

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

Setelah pemeriksa widget 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 mengambil Animation<Offset> yang menentukan offset awal dan akhir yang akan digunakan widget masuk dan keluar untuk berpindah.

Tween memiliki fungsi helper, animate(), yang mengonversi Animation apa pun menjadi Animation lain dengan tween yang diterapkan. Artinya, Tween dapat digunakan untuk mengonversi Animation yang disediakan oleh AnimatedSwitcher menjadi Animation, untuk diberikan 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 yang memiliki rentang 0,0 hingga 1,0, menjadi Tween yang bertransisi dari -0,1 hingga 0,0 pada sumbu x.

Atau, class Animasi memiliki fungsi drive() yang mengambil Tween (atau Animatable) dan mengonversinya menjadi Animation baru. Hal ini memungkinkan tween "dirantai", 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 menggunakan animasi eksplisit adalah animasi tersebut dapat disusun bersama. Tambahkan animasi eksplisit lain, FadeTransition yang menggunakan animasi melengkung yang sama dengan membungkus 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 sedikit masalah pada AnimationSwitcher. Saat QuestionCard beralih ke pertanyaan baru, pertanyaan tersebut akan ditata di tengah ruang yang tersedia saat animasi berjalan, tetapi saat animasi dihentikan, widget akan berpindah ke bagian atas layar. Hal ini menyebabkan animasi yang tidak lancar karena posisi akhir kartu pertanyaan tidak sesuai 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 pembuat 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 yang diubah dari defaultLayoutBuilder dari class AnimatedSwitcher, tetapi menggunakan Alignment.topCenter, bukan Alignment.center.

Ringkasan

  • Animasi eksplisit adalah efek animasi yang menggunakan objek Animation (berbeda dengan ImplicitlyAnimatedWidgets, yang menggunakan target value dan duration)
  • Class Animation merepresentasikan animasi yang sedang berjalan, tetapi tidak menentukan efek tertentu.
  • Gunakan Tween().animate atau Animation.drive() untuk menerapkan Tweens dan Curves (menggunakan CurveTween) ke animasi.
  • Gunakan parameter layoutBuilder AnimatedSwitcher untuk menyesuaikan cara penataan 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 Animation agar berfungsi dengan benar. Di bagian ini, Anda akan mempelajari cara membuat objek Animation 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 Anda
  3. Lakukan inisialisasi AnimationController dalam metode siklus proses initState, dengan memberikan objek State saat ini ke parameter vsync (TickerProvider).
  4. Pastikan widget Anda dibangun ulang setiap kali AnimationController memberi tahu pendengarnya, 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 kembali 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 dibangun kembali setiap kali AnimationController memberi tahu pendengarnya, dan widget Transform digunakan untuk menerapkan efek rotasi 3D guna menyimulasikan kartu yang dibalik.

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

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(                                    // NEW
        duration: const Duration(milliseconds: 300),            // NEW
        child: Card.filled(                                     // NEW
          key: ValueKey(answers[index]),                        // NEW
          color: color,
          elevation: 2,
          margin: EdgeInsets.all(8),
          clipBehavior: Clip.hardEdge,
          child: InkWell(
            onTap: () => onTapped(index),
            child: Padding(
              padding: EdgeInsets.all(16.0),
              child: Center(
                child: Text(
                  answers.length > index ? answers[index] : '',
                  style: Theme.of(context).textTheme.titleMedium,
                  overflow: TextOverflow.clip,
                ),
              ),
            ),
          ),
        ),                                                      // NEW
      );
    }),
  );
}

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

5455def725b866f6.gif

Anda mungkin memperhatikan bahwa class ini sangat mirip dengan efek animasi eksplisit. Sebenarnya, sebaiknya perluas class AnimatedWidget secara langsung untuk menerapkan versi Anda sendiri. Sayangnya, karena class ini perlu menyimpan widget sebelumnya di State, 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 pada 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 Animation baru yang menerapkan penundaan menggunakan TweenSequence. Perhatikan bahwa fungsi 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 untuk durasi relatif dan Tween reguler yang berjalan 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>([              // Add from here...
      if (widget.delayAmount > 0)
        TweenSequenceItem(
          tween: ConstantTween<double>(0.0),
          weight: widget.delayAmount,
        ),
      TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1.0),
    ]).animate(_animationController);                          // To here.
  }

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. Untuk 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 pada satu layar, tetapi cara lain untuk menggunakan animasi adalah dengan menggunakannya untuk bertransisi antar-layar. Di bagian ini, Anda akan mempelajari cara menerapkan efek animasi ke transisi layar menggunakan efek animasi bawaan dan efek animasi bawaan canggih yang disediakan oleh paket animations resmi di pub.dev.

Menganimasikan transisi navigasi

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

Untuk menyesuaikan animasi transisi, ganti MaterialPageRoute dengan PageRouteBuilder dan sesuaikan animasi transisi saat pengguna berpindah 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(                                         // Add from here...
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
              return FadeTransition(
                opacity: animation,
                child: child,
              );
            },
      ),                                                        // To here.
    );
  },
  child: Text('New Game'),
),

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(                     // Add from here...
                animation: animation,
                secondaryAnimation: secondaryAnimation,
                child: child,
              );                                                // To here.
            },
      ),
    );
  },
  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 bernavigasi. Animasi intip didorong oleh lokasi jari pengguna saat mereka menarik kembali melintasi 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 melakukan penggantian yang lancar ke perilaku gestur kembali asli dan ZoomPageTransitionBuilder. Lihat postingan blog kami untuk mengetahui informasi selengkapnya, termasuk bagian tentang cara menyiapkan fitur ini di aplikasi Anda sendiri.

Dalam konfigurasi ThemeData untuk aplikasi Anda, konfigurasikan PageTransitionsTheme untuk menggunakan PredictiveBack di Android, dan efek transisi fade-through 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),
        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() kembali menjadi MaterialPageRoute.

lib/home_screen.dart

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

Gunakan FadeThroughTransition untuk mengubah pertanyaan saat ini

Widget AnimatedSwitcher hanya menyediakan satu Animation dalam callback buildernya. 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(                              // Add from here...
      layoutBuilder: (entries) {
        return Stack(alignment: Alignment.topCenter, children: entries);
      },
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },                                                        // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

Menggunakan OpenContainer

77358e5776eb104c.png

Widget OpenContainer dari paket animations menyediakan efek animasi transformasi container yang meluas untuk membuat hubungan visual antara dua widget.

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

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

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 Card dengan widget OpenContainer dari paket animations, dengan menambahkan dua kolom baru untuk viewModel dan callback container 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 Curves dan Tweens 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 langkah 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