Flutterでのグラデーションの無限ループアニメーションの実装

こんにちは、アプリエンジニアの日野です。

今回は、実務の要件で実装したグラデーションの無限ループアニメーションの実装方法について書いて行きます。

この記事を見てわかること

  • Flutterでの単一方向のグラデーションのアニメーションの実装方法

検証環境

Flutter 3.13.7 にて確認しました。

fvm flutter doctor

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.13.7, on macOS 14.3.1 23D60 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.1)
[✓] VS Code (version 1.86.2)
[✓] Connected device (4 available)
[✓] Network resources

• No issues found!

検討した案について

グラデーションのアニメーションを実装するにあたって検討した案は以下の通りです。

  • gifや動画を背景に載せる
    • 実装は簡単ですが、グラデーションの色を変える場合、再度ファイルを差し替える必要があり保留しました
  • ライブラリなどを使用する
    • 調査時、グラデーションのアニメーションをターゲットにしたライブラリは確認できませんでした
  • 自作
    • 工数は幾分か余裕があったので、学習も兼ねてこの案を採用しました

自作するにあたって、以下の方法で実装をしました。

  • グラデーションのウィジェットを用意
  • AnimatedBuilderを使用してグラデーションが左から右へ移動するアニメーションを実装

工夫した点

ループ終了時と開始時の滑らかな切り替わり

アニメーションはAnimatedBuilderを使用することで比較的簡単に実装はできましたが、アニメーションの開始と終了でグラデーションのOffset位置が異なるため、不自然なアニメーションになってしまっていました。

上記の点を解決する為にとった方法は、余分にグラデーションの横幅を確保して、滑らかにループするように調整を行いました。

言葉だけですとわかりにくいと思いますので、以下にサンプルコードを記載します。

アニメーションを定義しているウィジェットを記載します。

/// ループアニメーション用ウィジェット
class LoopBackGround extends StatelessWidget {
  /// グラデーションのオフセット
  final Animation<Offset> gradationAnimation;
  /// アニメーションの再生、リピートなどを操作するコントローラー
  final AnimationController animationController;
  /// 内部に配置するウィジェット
  final Widget? child;
  /// アニメーションを表示する領域の横幅
  final double? fullWidth;
  /// アニメーションの有無
  final bool isAnimation;
  /// 初期値のオフセット
  final Offset? initialOffset;

  const LoopBackGround({
    required this.gradationAnimation,
    required this.animationController,
    this.child,
    this.fullWidth,
    this.isAnimation = true,
    this.initialOffset,
  });

  final gradateBeginColor = const Color.fromARGB(255, 220, 40, 110);
  final gradateEndColor = const Color.fromARGB(255, 0, 133, 255);

  @override
  Widget build(BuildContext context) {
    final backgroundBoxDecoration = LinearGradient(
      begin: Alignment.centerLeft,
      end: Alignment.centerRight,
      colors: [
        gradateBeginColor,
        gradateEndColor,
        gradateBeginColor,
        gradateEndColor,
        gradateBeginColor,
      ],
      stops: const [
        0.0,
        0.25,
        0.5,
        0.75,
        1.0,
      ],
    );

    return AnimatedBuilder(
      animation: animationController,
      child: child,
      builder: (BuildContext context, child) {
        return Stack(
          children: [
            Positioned(
              child: Transform.translate(
                offset: isAnimation
                    ? gradationAnimation.value + (initialOffset ?? Offset.zero)
                    : initialOffset ?? Offset.zero,
                child: SingleChildScrollView(
                  scrollDirection: Axis.horizontal,
                  physics: const NeverScrollableScrollPhysics(),
                  primary: false,
                  child: SizedBox(
                    width: fullWidth ?? MediaQuery.of(context).size.width * 5,
                    height: 78.0,
                    child: Ink(
                      decoration: BoxDecoration(
                        gradient: backgroundBoxDecoration,
                        borderRadius: BorderRadius.circular(6),
                      ), // 直接gradationを埋め込む
                    ),
                  ),
                ),
              ),
            ),
            if (child != null)
              Positioned(
                child: child,
              ),
          ],
        );
      },
    );
  }
}

次に上記のウィジェットを呼び出すコードを記載します。 一部flutter_hooksパッケージを使用している為、その箇所についてはコメントを追記しています。

// 呼び出し側のコード
class LoopTile extends HookWidget {
  const LoopTile({super.key});

  // : 以下buildメソッド
  @override
  Widget build(BuildContext context) {
    // アニメーション関連のsetup
    // useAnimationControllerを使用することで、LoopTileがdispose時に一緒にdispose処理が走るようになっています。
    final animationController =
        useAnimationController(duration: const Duration(seconds: 5));
    final startOffset = MediaQuery.of(context).size.width * -3;
    final endOffset = MediaQuery.of(context).size.width * -0.5;
    final tween =
        Tween<Offset>(begin: Offset(startOffset, 0), end: Offset(endOffset, 0));
    final gradationAnimation = tween.animate(animationController);
    // 以下2つ目の動画説明用のanimation
    const zeroOffset = Offset.zero;
    final twoFive = MediaQuery.of(context).size.width * 0.25;
    final slowTween = Tween<Offset>(begin: zeroOffset, end: Offset(twoFive, 0));
    final slowGradationAnimation = slowTween.animate(animationController);

    // ウィジェットがマウントされた時、実行される処理
    useEffect(() {
      animationController.repeat();
      return () {};
    }, []);

    return Container(
      height: 78.0,
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(borderRadius: BorderRadius.circular(6)),
      margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
      child: Material(
        child: InkWell(
          onTap: () {
            debugPrint('tapped');
          },
          child: LoopBackGround(
            gradationAnimation: gradationAnimation,
            animationController: animationController,
            child: const Padding(
              padding: EdgeInsets.symmetric(
                horizontal: 12.0,
              ),
              child: Center(
                child: Text('ここに表示させたいUIを置きます'),
              ),
            ),
          ),
        ),
      ),
    );
  // : 省略
  }
}

上記の処理を呼び出すと以下のようなアニメーションが実行されます。

挙動が確認できた所で、LoopBackGroundウィジェットについて説明します。

主要なウィジェットは以下の2つです。

  • AnimatedBuilder
    • アニメーションの秒数とアニメーションで変化する値を設定することで、UIにアニメーションを付与します。
  • Transform.translate
    • Offsetを使用してグラデーションをスライドさせます

また、SingleChildScrollView ウィジェットを使用することで画面幅以上のサイズを設定可能にしています。

上記のウィジェットが機能していることを以下の動画で確認できます。

上下同じウィジェットですが、実際に動作しているウィジェットは下のようにエリアを制限して表示させることで、無限ループしているように見せています。

リビルド処理をアニメーションのみに適用させる為のウィジェットの切り出し

こちらはパフォーマンスの面で工夫した点です。

最初はAnimatedBuilder内にUIも全て配置して1つのウィジェットとして実装をしていましたが、テストの際に何度もリビルドが動作していることを確認し、アニメーションの部分のみ別ウィジェットに切り出す作業を行いました。

実際には今回のサンプルより複雑なUIだった為、切り出す作業が少し大変でしたが無事リビルドがアニメーションのウィジェットのみ適用されるようにできました。

拡張機能

今回作成したウィジェットは左から右へ移動するだけのシンプルなアニメーションでしたが、以下のような機能追加が可能です。

  • 移動方向を反対方向や縦方向に変える処理の追加
  • グラデーションの組み合わせや勾配の変更処理
  • グラデーション以外の背景の適用処理
  • flutter_animate ライブラリを使用しよりシンプルに実装

まとめ

今回初めてアニメーションをアプリ内に組み込みました。

最初こそAnimation関連のクラスがたくさんあり実装に時間がかかりましたが1つのシンプルなアニメーションであれば、比較的簡単に組み込むことができました。

アニメーションの実装はネイティブアプリに比べると実装コストは低いと思うので

この強みを活かして今後はflutter_animateを駆使してよりシンプルもしくは複雑なアニメーションを実装していきたいです。