🃏

Flutterのアニメーション触ってみた!

2021/12/29に公開

はじめに

アニメーションに興味が湧いて、Flutterでアニメーション入門しました。
誤った認識等ございましたら、コメント欄でご教授の程よろしくお願い致します🙇


やりたいこと

色々と書きたいことはあるのですが、ここでは基本的なアニメーションを実例を用いて紹介したいと思います!
実際に見て頂いたほうが早いと思うので、以下gifを御覧ください。

4つのカードを開いたとき、4つのカードに書かれたポイントの合計をダイアログで表示するというものです。

カードがはみ出てるの気になる...

↓こちらだと、カードが画面内に収まっていることが確認できるかと思います!
  ただPCでないと見れないかもしれません🙇

https://user-images.githubusercontent.com/63396451/147625511-26ff0678-c7dd-4ee4-b928-123211451996.MP4


ベースとなるコード

アニメーションを導入する前に、ベースとなるコードを確認します!

import 'package:flutter/material.dart';
import 'package:flutter_animation/constanins.dart';

class CardPage extends StatelessWidget {
  const CardPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        backgroundColor: const Color(0x44000000),
      ),
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constrains) {
            return GridView.builder(
              itemCount: 4,
              physics: const NeverScrollableScrollPhysics(),
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                mainAxisSpacing: 24,
                crossAxisSpacing: 24,
                childAspectRatio: constrains.maxWidth / constrains.maxHeight,
              ),
              itemBuilder: (context, index) => Container(
                padding: const EdgeInsets.all(defaultPadding),
                decoration: BoxDecoration(
                  color: primaryColor.withOpacity(0.1),
                  border: Border.all(color: primaryColor),
                  borderRadius: const BorderRadius.all(Radius.circular(6)),
                ),
                child: Center(
                  child: Text.rich(
                    TextSpan(
                      text: '0',
                      style: Theme.of(context).textTheme.headline4!.copyWith(
                            fontWeight: FontWeight.w600,
                            color: Colors.white,
                          ),
                      children: const [
                        TextSpan(
                          text: "pt",
                          style: TextStyle(fontSize: 24),
                        )
                      ],
                    ),
                  ),
                ),
              ),
            );
          },
        ),
      ),
    );
  }
}

主なWidget

LayoutBuilder
  • 親Widgetが子Widgetのサイズを制約して、子の固有サイズに依存しない場合に便利
  • 最初にレイアウトされた時や画面のサイズが変化した時にしか呼び出されない

以下公式のyoutubeサイト
https://www.youtube.com/watch?v=IYDVcriKjsw&feature=youtu.be

SliverGridDelegateWithFixedCrossAxisCount
  • 横軸に固定数のタイルを持つグリッドレイアウトを作成する
  • 各タイルの縦横比(childAspectRatio)やタイル間の間隔(mainAxisSpacing,crossAxisSpacing)などを指定できる

以下公式サイト
https://api.flutter.dev/flutter/rendering/SliverGridDelegateWithFixedCrossAxisCount-class.html


また、各カードのポイントやカードが開いているかどうかのbool値(アニメーション導入時使用)は状態管理の対象とします。
状態管理にはriverpodを用いています。

card_notifier.dart
import 'dart:math';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'card_notifier.freezed.dart';


class CardState with _$CardState {
  const factory CardState({
    (0) int ptOfCard1,
    (0) int ptOfCard2,
    (0) int ptOfCard3,
    (0) int ptOfCard4,
    (false) bool isOpenCard1,
    (false) bool isOpenCard2,
    (false) bool isOpenCard3,
    (false) bool isOpenCard4,
  }) = _CardState;
}

final cardNotifierProvider =
    StateNotifierProvider.autoDispose<CardNotifier, CardState>((ref) {
  return CardNotifier(ref.read);
});

class CardNotifier extends StateNotifier<CardState> {
  CardNotifier(this._read) : super(const CardState());
  final Reader _read;

  void Function()? toOpenCard1() {
    state = state.copyWith(
      isOpenCard1: true,
      ptOfCard1: Random().nextInt(26),
      // Random().nextInt(26) は 0 ~ 25 までのいずれかをランダムで表す
    );
  }

  void Function()? toOpenCard2() {
    state = state.copyWith(
      isOpenCard2: true,
      ptOfCard2: Random().nextInt(26),
    );
  }

  void Function()? toOpenCard3() {
    state = state.copyWith(
      isOpenCard3: true,
      ptOfCard3: Random().nextInt(26),
    );
  }

  void Function()? toOpenCard4() {
    state = state.copyWith(
      isOpenCard4: true,
      ptOfCard4: Random().nextInt(26),
    );
  }
}

view側は次のようになります。
主要なところだけハイライトつけています。

card_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_animation/UI/card_notifier.dart';
import 'package:flutter_animation/constanins.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

-class CardPage extends StatelessWidget {
+class CardPage extends ConsumerWidget {
  const CardPage({Key? key}) : super(key: key);

  
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, WidgetRef ref) {
+   final state = ref.watch(cardNotifierProvider);
+   final notifier = ref.watch(cardNotifierProvider.notifier);

    List<int> listCard = [
      state.ptOfCard1,
      state.ptOfCard2,
      state.ptOfCard3,
      state.ptOfCard4
    ];

    List<void Function()?> onTap = [
      notifier.toOpenCard1,
      notifier.toOpenCard2,
      notifier.toOpenCard3,
      notifier.toOpenCard4,
    ];

    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        backgroundColor: const Color(0x44000000),
      ),
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constrains) {
            return GridView.builder(
              itemCount: 4,
              physics: const NeverScrollableScrollPhysics(),
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                mainAxisSpacing: 24,
                crossAxisSpacing: 24,
                childAspectRatio: constrains.maxWidth / constrains.maxHeight,
              ),
              itemBuilder: (context, index) {
+                return GestureDetector(
+                 onTap: onTap[index],
                  child: Container(
                    padding: const EdgeInsets.all(defaultPadding),
                    decoration: BoxDecoration(
                      color: primaryColor.withOpacity(0.1),
                      border: Border.all(color: primaryColor),
                      borderRadius: const BorderRadius.all(Radius.circular(6)),
                    ),
                    child: Center(
                      child: Text.rich(
                        TextSpan(
+                         text: listCard[index].toString(),
                          style:
                              Theme.of(context).textTheme.headline3!.copyWith(
                                    fontWeight: FontWeight.w600,
                                    color: Colors.white,
                                  ),
                          children: const [
                            TextSpan(
                              text: "pt",
                              style: TextStyle(fontSize: 24),
                            )
                          ],
                        ),
                      ),
                    ),
                  ),
                );
              },
            );
          },
        ),
      ),
    );
  }
}

これで一先ず各カードをタップすると数値が変わるようになりました。


アニメーション導入

さて下準備は以上で、本題のアニメーションを導入していきます!
アニメーションしたい点は2点で、

  • カードが回転するアニメーション
  • ダイアログが開いた後、各カードのポイントがフェードアウトするアニメーション

です。

コードは以下のトグルボタンから確認してください。

card_notifier.dart
+import 'dart:math';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'card_notifier.freezed.dart';

@freezed
class CardState with _$CardState {
  const factory CardState({
    (0) int ptOfCard1,
    (0) int ptOfCard2,
    (0) int ptOfCard3,
    (0) int ptOfCard4,
    (false) bool isOpenCard1,
    (false) bool isOpenCard2,
    (false) bool isOpenCard3,
    (false) bool isOpenCard4,
+   (0) double angle1,
+   (0) double angle2,
+   (0) double angle3,
+   (0) double angle4,
  }) = _CardState;
}

final cardNotifierProvider =
    StateNotifierProvider.autoDispose<CardNotifier, CardState>((ref) {
  return CardNotifier(ref.read);
});

class CardNotifier extends StateNotifier<CardState> {
  CardNotifier(this._read) : super(const CardState());
  final Reader _read;

  void Function()? toOpenCard1() {
    state = state.copyWith(
      isOpenCard1: true,
      ptOfCard1: Random().nextInt(26),
+     angle1: (state.angle1 + pi) % (2 * pi),
    );
  }

  void Function()? toOpenCard2() {
    state = state.copyWith(
      isOpenCard2: true,
      ptOfCard2: Random().nextInt(26),
+     angle2: (state.angle2 + pi) % (2 * pi),
    );
  }

  void Function()? toOpenCard3() {
    state = state.copyWith(
      isOpenCard3: true,
      ptOfCard3: Random().nextInt(26),
+     angle3: (state.angle3 + pi) % (2 * pi),
    );
  }

  void Function()? toOpenCard4() {
    state = state.copyWith(
      isOpenCard4: true,
      ptOfCard4: Random().nextInt(26),
 +    angle4: (state.angle4 + pi) % (2 * pi),
    );
  }
}
card_page.dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_animation/UI/card_notifier.dart';
import 'package:flutter_animation/constanins.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class CardPage extends ConsumerWidget {
  const CardPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(cardNotifierProvider);
    final notifier = ref.watch(cardNotifierProvider.notifier);

    List<int> listCard = [
      state.ptOfCard1,
      state.ptOfCard2,
      state.ptOfCard3,
      state.ptOfCard4
    ];

    List<void Function()?> onTap = [
      notifier.toOpenCard1,
      notifier.toOpenCard2,
      notifier.toOpenCard3,
      notifier.toOpenCard4,
    ];

    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        backgroundColor: const Color(0x44000000),
      ),
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constrains) {
            return GridView.builder(
              itemCount: 4,
              physics: const NeverScrollableScrollPhysics(),
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                mainAxisSpacing: 24,
                crossAxisSpacing: 24,
                childAspectRatio: constrains.maxWidth / constrains.maxHeight,
              ),
              itemBuilder: (context, index) {
                final isOpen = [
                  state.isOpenCard1,
                  state.isOpenCard2,
                  state.isOpenCard3,
                  state.isOpenCard4,
                ][index];

                final angle = [
                  state.angle1,
                  state.angle2,
                  state.angle3,
                  state.angle4,
                ];

                return GestureDetector(
                  onTap: onTap[index],
                  child: TweenAnimationBuilder(
                    tween: Tween<double>(begin: 0, end: angle[index]),
                    duration: defaultDuration,
                    builder: ((BuildContext context, double val, __) {
                      return Transform(
                        alignment: Alignment.center,
                        transform: Matrix4.identity()
                          ..setEntry(3, 2, 0.001)
                          ..rotateY(val),
                        child: Transform(
                          alignment: Alignment.center,
                          transform: Matrix4.identity()..rotateY(math.pi),
                          child: Container(
                            padding: const EdgeInsets.all(defaultPadding),
                            decoration: BoxDecoration(
                              color: primaryColor.withOpacity(0.1),
                              border: Border.all(color: primaryColor),
                              borderRadius:
                                  const BorderRadius.all(Radius.circular(6)),
                            ),
                            child: AnimatedOpacity(
                              duration: defaultDuration1,
                              opacity: isOpen ? 1 : 0,
                              child: Center(
                                child: Text.rich(
                                  TextSpan(
                                    text: listCard[index].toString(),
                                    style: Theme.of(context)
                                        .textTheme
                                        .headline3!
                                        .copyWith(
                                          fontWeight: FontWeight.w600,
                                          color: Colors.white,
                                        ),
                                    children: const [
                                      TextSpan(
                                        text: "pt",
                                        style: TextStyle(fontSize: 24),
                                      )
                                    ],
                                  ),
                                ),
                              ),
                            ),
                          ),
                        ),
                      );
                    }),
                  ),
                );
              },
            );
          },
        ),
      ),
    );
  }
}

ポイント

  • カードの(半)回転に必要なπを使用するためにmathをインポートし、状態管理に記述したangleにはカードをタップしたときに(state.angle1 + pi) % (2 * pi)とし、半回転を表現
TweenAnimationBuilder
  • Widgetのプロパティがターゲット値に変化が起こるたびにアニメーションを行うWidgetBuilder
  • 今回はTweenのタイプにTween<double>を用いているが、他にもColorTweenRectTweenがある
  • Tweenはアニメーションの目標値も定義でき、ウィジェットが最初にビルドされると、Tween.beginからTween.endまでアニメーションが行われる

以下公式サイト
https://api.flutter.dev/flutter/widgets/TweenAnimationBuilder-class.html

Transform
  • Transformは子ウィジェットを描画する前に変形を適用するウィジェットで、このオブジェクトは描画の直前に変形を適用する
  • 指定するプロパティのtransformにはMatrix4を付与することで4Dアクションを表現できます。(この辺り知見を深めたい...)

今回の例では

alignment: Alignment.center,
transform: Matrix4.identity()..rotateY(math.pi),

この辺りのプロパティの付与で、カードを縦中央を軸として回転を表現出来ています。

以下公式サイト
https://api.flutter.dev/flutter/widgets/Transform-class.html

AnimatedOpacity
  • Opacityのアニメーション版
  • 与えられた不透明度が変化するたびに、与えられた時間(duration)にわたって自動的に子Widgetの不透明度を遷移(フェードアウト/フェードイン)
  • Animated~とついたWidgetは他にもAnimatedContainer,AnimatedDefaultTextStyle,AnimatedPositioned,AnimatedSwitcher,AnimatedSwitcherなど多く用意されている

以下公式サイト
https://api.flutter.dev/flutter/widgets/AnimatedOpacity-class.html


ダイアログを開く

最後に4つのカードが開いたタイミングにダイアログを出す機能を実装します。

コードは以下のトグルボタンで確認してください。

card_notifier.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'card_notifier.freezed.dart';

@freezed
class CardState with _$CardState {
  const factory CardState({
    (0) int ptOfCard1,
    (0) int ptOfCard2,
    (0) int ptOfCard3,
    (0) int ptOfCard4,
+   (0) int totalPt,
    (false) bool isOpenCard1,
    (false) bool isOpenCard2,
    (false) bool isOpenCard3,
    (false) bool isOpenCard4,
    (0) double angle1,
    (0) double angle2,
    (0) double angle3,
    (0) double angle4,
  }) = _CardState;
}

final cardNotifierProvider =
    StateNotifierProvider.autoDispose<CardNotifier, CardState>((ref) {
  return CardNotifier(ref.read);
});

class CardNotifier extends StateNotifier<CardState> {
  CardNotifier(this._read) : super(const CardState());
  final Reader _read;

  void Function()? toOpenCard1() {
    state = state.copyWith(
      isOpenCard1: true,
      ptOfCard1: Random().nextInt(26),
      angle1: (state.angle1 + pi) % (2 * pi),
    );
  }

  void Function()? toOpenCard2() {
    state = state.copyWith(
      isOpenCard2: true,
      ptOfCard2: Random().nextInt(26),
      angle2: (state.angle2 + pi) % (2 * pi),
    );
  }

  void Function()? toOpenCard3() {
    state = state.copyWith(
      isOpenCard3: true,
      ptOfCard3: Random().nextInt(26),
      angle3: (state.angle3 + pi) % (2 * pi),
    );
  }

  void Function()? toOpenCard4() {
    state = state.copyWith(
      isOpenCard4: true,
      ptOfCard4: Random().nextInt(26),
      angle4: (state.angle4 + pi) % (2 * pi),
    );
  }

+ Future<void> toShowDialog(BuildContext context) async {
+   if (state.isOpenCard1 &&
+       state.isOpenCard2 &&
+       state.isOpenCard3 &&
+       state.isOpenCard4) {
      state = state.copyWith(
        totalPt: state.ptOfCard1 +
            state.ptOfCard2 +
            state.ptOfCard3 +
            state.ptOfCard4,
      );
      await Future.delayed(const Duration(milliseconds: 500));
      _showDialog(context);
+     await Future.delayed(const Duration(milliseconds: 800));
+     state = state.copyWith(
+       isOpenCard1: false,
+       isOpenCard2: false,
+       isOpenCard3: false,
+       isOpenCard4: false,
+     );
    }
  }
  
  Future<Widget?> _showDialog(BuildContext context) {
    return showDialog<Widget>(
      context: context,
      builder: (context) {
        return AlertDialog(
          backgroundColor: Colors.transparent,
          title: Center(
            child: Text.rich(
              TextSpan(
                text: '${state.totalPt}',
                style: Theme.of(context).textTheme.headline3!.copyWith(
                      fontWeight: FontWeight.w600,
                      color: Colors.white,
                    ),
                children: const [
                  TextSpan(
                    text: "pt",
                    style: TextStyle(fontSize: 24),
                  )
                ],
              ),
            ),
          ),
        );
      },
    );
  }
}
card_page.dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_animation/UI/card_notifier.dart';
import 'package:flutter_animation/constanins.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

-class CardPage extends ConsumerWidget {
+class CardPage extends HookConsumerWidget {
  const CardPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(cardNotifierProvider);
    final notifier = ref.watch(cardNotifierProvider.notifier);

    List<int> listCard = [
      state.ptOfCard1,
      state.ptOfCard2,
      state.ptOfCard3,
      state.ptOfCard4
    ];

    List<void Function()?> onTap = [
      notifier.toOpenCard1,
      notifier.toOpenCard2,
      notifier.toOpenCard3,
      notifier.toOpenCard4,
    ];

+   useEffect(() {
+     WidgetsBinding.instance?.addPostFrameCallback((_) async {
+       await notifier.toShowDialog(context);
+     });
+   }, [
+     state.isOpenCard1,
+     state.isOpenCard2,
+     state.isOpenCard3,
+     state.isOpenCard4,
+   ]);

    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        backgroundColor: const Color(0x44000000),
      ),
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constrains) {
            return GridView.builder(
              itemCount: 4,
              physics: const NeverScrollableScrollPhysics(),
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                mainAxisSpacing: 24,
                crossAxisSpacing: 24,
                childAspectRatio: constrains.maxWidth / constrains.maxHeight,
              ),
              itemBuilder: (context, index) {
                final isOpen = [
                  state.isOpenCard1,
                  state.isOpenCard2,
                  state.isOpenCard3,
                  state.isOpenCard4,
                ][index];

                final angle = [
                  state.angle1,
                  state.angle2,
                  state.angle3,
                  state.angle4,
                ];
                return GestureDetector(
                  onTap: onTap[index],
                  child: TweenAnimationBuilder(
                    tween: Tween<double>(begin: 0, end: angle[index]),
                    duration: defaultDuration,
                    builder: ((BuildContext context, double val, __) =>
                        Transform(
                          alignment: Alignment.center,
                          transform: Matrix4.identity()
                            ..setEntry(3, 2, 0.001)
                            ..rotateY(val),
                          child: Transform(
                            alignment: Alignment.center,
                            transform: Matrix4.identity()..rotateY(math.pi),
                            child: Container(
                              padding: const EdgeInsets.all(defaultPadding),
                              decoration: BoxDecoration(
                                color: primaryColor.withOpacity(0.1),
                                border: Border.all(color: primaryColor),
                                borderRadius:
                                    const BorderRadius.all(Radius.circular(6)),
                              ),
                              child: AnimatedOpacity(
                                duration: defaultDuration1,
                                opacity: isOpen ? 1 : 0,
                                child: Center(
                                  child: Text.rich(
                                    TextSpan(
                                      text: listCard[index].toString(),
                                      style: Theme.of(context)
                                          .textTheme
                                          .headline3!
                                          .copyWith(
                                            fontWeight: FontWeight.w600,
                                            color: Colors.white,
                                          ),
                                      children: const [
                                        TextSpan(
                                          text: "pt",
                                          style: TextStyle(fontSize: 24),
                                        )
                                      ],
                                    ),
                                  ),
                                ),
                              ),
                            ),
                          ),
                        )),
                  ),
                );
              },
            );
          },
        ),
      ),
    );
  }
}

ポイント

  • hooksの一つであるuseEffectを使うため、ConsumerWidgetHookConsumerWidgetに変更
  • useEffectの第1引数には、すべてのカードが開いたタイミングでダイアログを開く処理
  • useEffectの第2引数には、各isOpenCard与える事で第1引数の関数が実行されるタイミングをコントロール
  • Future.delayed(const Duration(milliseconds: 800))で非同期処理

他にもhooksの一つであるuseAnimationControllerを使って色々とアニメーションを組み込んでみたかったのですが、良い実例が思い浮かばなかったです🤷‍♂️
また時間を取って考えてみたいと思います!

以上になります。
ここまで読んでいただきありがとうございました!!

Discussion