0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter animation

Last updated at Posted at 2025-10-31

はじめに

Flutter の公式サイトを中心にアニメーションの基礎を理解したい。

Flutter のソースコードは画面右上のアイコンをクリックすることで見ることができる。

Screenshot 2025-10-19 at 8.25.12.png

【解像度 0】 アニメーションとは

アニメーションは Frame と呼ばれる描画処理を高速に繰り返すことによって実現する。

パラパラ漫画で言う、ひとコマひとコマが Frame であり、ディスプレイの世界では一般的に 1 秒間に 60 回 Frame が更新される 60 fps(frame per second)がヌルヌル動くと言われる。

image.png
https://cameragurus.com/30fps-vs-60fps/

image.png
https://32labo.com/framerate/

Flutter には以下の 2 種類のアニメーション対象が存在する。

  • Drawing-based animations
  • Code-based animation

アニメーションのさせ方は以下の 2 種類に分類される。

  • Tween animation
  • Physics-based animation

Drawing-based animations

ゲームのキャラクターのような Canvas に絵を描くようにして描画された対象物を、Frame の度に手動で変化を加えてアニメーションさせるもの。

既存の WidgetRowColumntextStyle)で表現できない

  • ベクター画像(数式、直線、曲線で表現された画像)
  • ラスター画像(ピクセルの集合で表現された画像)

を使用するものに対して適用する。

  • RiveLottie などのサードパーティ製ツールが充実している
  • CustomPainter を使用する

Code-based animation

Widget のプロパティを変化させることで実現するアニメーション。

※ この記事のほとんどは、Code-based animation に関わるものです。

Tween animation

アニメーションの「始点」と「終点」を定義しておき、その間を補間させることによって実現するアニメーション。

TweenBetween に由来している。

※ この記事のほとんどは、Tween animation に関わるものです。

Physics-based animation

現実世界の物理法則を模倣したモデルによって実現するアニメーション。

参考: Animate a widget using a physics simulation

【解像度 1】 Flutter のアニメーション

Code-based animation は以下の 2 つに大別される。

  • Implicit Animation(暗黙的アニメーション)
  • Explicit Animation(明示的アニメーション)

実現したいアニメーションが単純であれば Implicit Animation を使う。

「難しい」アニメーションの場合には Explicit Animation を使用する必要が出てくる。

「難しい」とは、アニメーションが以下の特性を持つ場合を指す。

  • 永続的
  • 連続性
  • 複数の Widget が連携してアニメーションする

Implicit Animation

WidgetColor など、特定のプロパティを A → B に遷移させるようなアニメーション。

A → B に変えたときは setState() を呼ぶだけ。

Implicit Animation
class ImplicitAnimation extends StatefulWidget {
  const ImplicitAnimation({super.key});

  @override
  State<ImplicitAnimation> createState() => _ImplicitAnimationState();
}

class _ImplicitAnimationState extends State<ImplicitAnimation> {
  bool isRed = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => isRed = !isRed),  // 👈
      child: AnimatedContainer(
        duration: const Duration(seconds: 2),
        width: 100,
        height: 100,
        color: isRed ? Colors.red : Colors.blue,  // 👈
      ),
    );
  }
}

Explicit Animation

AnimationController を必要とするアニメーション。

AnimationController.forward() による「明示的な」アニメーションの開始が必要。

AnimationControllerWidget ではないため、StatefulWidget 内に配置 する必要があり、AnimationController オブジェクトのライフサイクルを管理する必要がある(initState() 内でインスタンス生成、dispose() 内での破棄)。

Explicit Animation
class ExplicitAnimation extends StatefulWidget {
  const ExplicitAnimation({super.key});

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return const XXX();
  }
}

Explicit Animation において AnimationController は必須の存在であり、この処理は避けることができない。

このため Explicit Animation のコードは Implicit Animation よりも量や複雑度が増す傾向にある。

Implicit vs Explicit

Animation AnimationController
Implicit Animation 不要
Explicit Animation 必要

Animation Widget の選び方

Implicit / Explicit のどちらを使用すべきかについては、「実現したいこと」に対して適切な選択をする必要がある。

複雑なアニメーションにしようとすればするほど、より低レベルな Flutter ライブラリを使用する必要性がある。

image.png
https://blog.flutter.dev/how-to-choose-which-flutter-animation-widget-is-right-for-you-79ecfb7e72b5

難易度

難易度
easier < Implicit < Explicit < CustomPainter < harder

image.png
https://blog.flutter.dev/how-to-choose-which-flutter-animation-widget-is-right-for-you-79ecfb7e72b5

【解像度 2】 暗黙的 / 明示的アニメーションとは

Implicit Animation

Implicit Animation は以下の 2 種類に分類される。

  • Built-in
    • Flutter SDK に含まれる Widget
    • AnimatedFoo
    • AnimatedContainer
    • AnimatedOpacity
    • ...
  • Custom
    • TweenAnimationBuilder を使用して自前で作成する Widget

Built-in(Flutter SDK に含まれる Widget)から目的の Widget が探せなかった場合、カスタム Widget を作成する必要がある

Explicit Animation

Explicit Animation は以下の 2 種類に分類される。

  • Built-in
    • Flutter SDK に含まれる Widget
    • FooTransition
    • SizeTransition
    • SlideTransition
    • ...
  • Custom
    • AnimatedBuilder を使用して自前で作成する Widget
    • AnimatedWidget のサブクラスとして自前で作成する Widget

Implicit vs Explicit

Implicit Animation を使用するか、Explicit Animation を使用するかの判断基準。

下記のいずれかの必要性がある場合、Explicit Animation が必要。

  • 永続的
  • 連続性 があるか
    • 開いて閉じる、開いて閉じる → 連続性あり(全開状態から徐々に未開状態に遷移)
    • 「開く」を繰り返す → 連続性なし(全開状態から未開状態に状態がジャンプするため)
  • 複数の Widget が協調 するか

ただし、Implicit Animation でできる全てのことは、Explicit Animation によって実現することが可能である。

上記 3 つのいずれにも該当しないアニメーションは、 Flutter 公式では Basic Animaiton と表現される。

Basic AnimationImplicit Animation で実現することができる。

【解像度 3】 アニメーションを実装する(導入)

Built-in Implicit Animation

Flutter には Widget 名の先頭が Animated で始まる Built-in Implicit Animation がたくさん用意されている。

これらクラスは全て ImplicityAnimatedWidget を継承したクラス群となっている。

Custom Explicit Animation

Explicit Animation を理解するためには Animation<T> への理解が必須である。

Animation<T> (abstract class)

image.png

UI に T valueAnimationStatus status を提供する 抽象クラス。

  • T value
    • ハードウェアが新たな Frame を用意する度に生成される
      • 60 fps(frame per seconds: 1 秒間に 60 回 frame を更新する)の環境においては、1 秒間に 60 回生成値を返す
    • 生成される値
      • 与えられた始点と終点の間を 線形補間 or 非線形補間 した値
  • AnimationStatus status
    • アニメーションの状態
      • dismissed
        • 0.01.0 に進行中
      • forward
        • 1.00.0 に進行中
      • completed
        • 1.0 に到達
      • dismissed
        • 0.0 に到達

UI に value を提供するが、UI(どのように表示されているか)についての知識は持たない

型引数 Tdouble が最もよく利用されるが、それ以外にも ColorSize などのオブジェクトに関しても補間することができる。

Animation<T> は UI に関する情報を持たない

AnimationController

image.png

抽象クラス Animation<double>を継承した具象クラス。

線形補間された値が生成される。

image.png
https://api.flutter.dev/flutter/animation/Curves/linear-constant.html

デフォルトでは生成値の取りうる値の範囲は 0.0 ~ 1.0double 型)。

0.0 ~ 1.0 以外の範囲の値を使用したい場合、Tween を使用する。

AnimationController は加えて以下のアニメーションを開始、停止するためのコントローラ機能も併せ持つ

このコントローラ機能は アニメーションを drive(駆動する、操縦する) と表現されることがある。

  • forward()
  • stop()
  • reverse()
  • repeat()

CurvedAnimation

image.png

抽象クラス Animation<double> を継承した具象クラス。

Animation<double> が提供する double t非線形補間(non-linear curve)によって変換する

Curve の種類によっては、非線形補間された値は 0.0 ~ 1.0 の範囲外の値になることもある。

非線形補間においては、時間の進行に対して値の変化速度が一定でない。

image.png
https://api.flutter.dev/flutter/animation/Curves-class.html

AnimationController と同様に Animation<double> を継承する一方で、CurvedAnimationコントローラとしての機能を持たない

コントローラ機能は外部から注入する必要がある。

CurvedAnimation
final Animation<double> animation = CurvedAnimation(
  parent: controller, // 👈 コントローラ機能を注入
  curve: Curves.ease,
);

デフォルトでは 0.0 ~ 1.0 の範囲を持つ。

CurvedAnimation0.0 ~ 1.0 を非線形(曲線 = Curve)補間している

Animation vs AnimationController vs CurvedAnimation

image.png

AnimationControllerCurvedAnimation はどちらも Animation<duble> を継承しているため、型としては互換性を持ち、同じ API(valueaddListener()) が共有できるが、コントローラ機能の互換性は無い。

クラス名 継承関係 出力値 補間 コントローラ機能
Animation<T> 抽象クラス T value - -
AnimationController 具象クラス 0.0 ~ 1.0 線形補間 あり
CurvedAnimation 具象クラス 0.0 ~ 1.0(デフォルト) 非線形補間 -

アニメーションのコントローラ機能を有する と言う点では、明示的な(Explicit)アニメーションで重要な役割を担う AnimationController は、唯一無二の特別な存在である。

アニメーションを明示的に開始したり、停止できるコントローラ機能を持ったクラスは他には存在しない。

AnimationController はアニメーションを動かす(drive する)唯一の動力

AnimationControllerCurvedAnimation はどちらも Animation<double> 型の変数に代入できるのに、変数名が animation だったり contorller だったりするのは、コントローラ機能の有無の違いによるものである。

CurvedAnimation 自体はコントローラを持たず、親(parent)に指定された AnimationController によって駆動(drive)される。

AnimationController vs CurvedAnimation
final Animation<double> controller = AnimationController(
  duration: const Duration(seconds: 2),
  vsync: this,
);

final Animation<double> animation = CurvedAnimation(
  parent: controller,
  curve: Curves.ease,
);

【解像度 4】 アニメーションを実装する(実践)

Built-in Implicit Animation

【実践 1】 基本的なアニメーション

Implicit AnimationAnimatedFoo ウィジェットのプロパティを setState() 内で変更するだけで実現する。

Implicit Animation
class _ImplicitAnimationState extends State<ImplicitAnimation> {
  bool visible = false;
  late Timer timer;

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(
      const Duration(seconds: 2),
      (Timer timer) => setState(() => visible = !visible),  // 👈
    );
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedOpacity(
      opacity: visible ? 1.0 : 0.0,  // 👈

Screen Recording 2025-10-24 at 23.07.42.gif

↑コード
Implicit Animation
import 'dart:async';

import 'package:flutter/material.dart';

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

  @override
  State<ImplicitAnimation> createState() => _ImplicitAnimationState();
}

class _ImplicitAnimationState extends State<ImplicitAnimation> {
  bool visible = false;
  late Timer timer;

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(
      const Duration(seconds: 2),
      (Timer timer) => setState(() {
        visible = !visible;
      }),
    );
  }

  @override
  void dispose() {
    timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedOpacity(
      opacity: visible ? 1.0 : 0.0,
      duration: const Duration(seconds: 2),
      child: Container(
        width: 100,
        height: 100,
        color: Colors.red,
      ),
    );
  }
}

Custom Explicit Animation

【実践 1】 基本的なアニメーション

image.png

AnimationControllervalue が変更された際に実行されるリスナを addListener() で登録することができる。

この addListener() では setState() を呼び出すことで、value の変更時に再描画を実行させることができる。

Explicit Animation
controller = AnimationController(
  duration: const Duration(seconds: 2),
  vsync: this,
)
..addListener(() => setState(() {}),

同様に status が変更された際のリスナも addStatusListener() で登録することができる。

Explicit Animation
..addStatusListener(
  (status) {
    if (status == AnimationStatus.completed) {
      controller.reverse();
    } else if (status == AnimationStatus.dismissed) {
      controller.forward();
    }
  },
)

これによって、最も基本的な Explicit Animation を構築できる。

Screen Recording 2025-10-24 at 19.10.46.gif

ソースコード
Explicit Animation
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    )
      ..addListener(
        () => setState(() {}),
      )
      ..addStatusListener(
        (status) {
          if (status == AnimationStatus.completed) {
            controller.reverse();
          } else if (status == AnimationStatus.dismissed) {
            controller.forward();
          }
        },
      )
      ..forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 100 * controller.value,
      height: 100 * controller.value,
      color: Colors.red,
    );
  }
}

Screen Recording 2025-10-26 at 11.32.13.gif

↑ソースコード(ステータス表示)
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 5),
      vsync: this,
    )
      ..addListener(
        () => setState(() {}),
      )
      ..addStatusListener(
        (status) {
          if (status == AnimationStatus.completed) {
            controller.reverse();
          } else if (status == AnimationStatus.dismissed) {
            controller.forward();
          }
        },
      )
      ..forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    const style = TextStyle(
      fontSize: 40,
    );
    return Stack(
      children: [
        Positioned(
          right: 100,
          top: 100,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              Text(
                'value: ${controller.value.toStringAsFixed(2)}',
                style: style,
              ),
              Text(
                'status: ${controller.status.name}',
                style: style,
              ),
              Text(
                'velocity: ${controller.velocity.toStringAsFixed(2)}',
                style: style,
              ),
            ],
          ),
        ),
        Align(
          alignment: AlignmentGeometry.center,
          child: Container(
            width: 100 * controller.value,
            height: 100 * controller.value,
            color: Colors.red,
          ),
        ),
      ],
    );
  }
}

【実践 2】 non-linear なアニメーション(Curve)

前述の通り、AnimationController は線形補間した value を提供する。

image.png
https://api.flutter.dev/flutter/animation/Curves-class.html

これを CurvedAnimation を用いて 非線形補間(no-linear curve)に変換する。

Explicit Animation
animation = CurvedAnimation(parent: controller, curve: Curves.easeInCirc);

image.png

https://api.flutter.dev/flutter/animation/Curves-class.html

WidgetCurvedAnimation が提供する double value によって描画を行う。

Screen Recording 2025-10-24 at 19.49.41.gif

ソースコード
Explicit Animation
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
+  late Animation<double> animation;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    )
      ..addListener(
        () => setState(() {}),
      )
      ..addStatusListener(
        (status) {
          if (status == AnimationStatus.completed) {
            controller.reverse();
          } else if (status == AnimationStatus.dismissed) {
            controller.forward();
          }
        },
      );
+    animation = CurvedAnimation(parent: controller, curve: Curves.easeInCirc);
    controller.forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
+      width: 100 * animation.value,
+      height: 100 * animation.value,
-      width: 100 * controller.value,
-      height: 100 * controller.value,
      color: Colors.red,
    );
  }
}

Screen Recording 2025-10-26 at 11.34.58.gif

ソースコード(ステータス表示)
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late Animation<double> animation;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 5),
      vsync: this,
    )
      ..addListener(
        () => setState(() {}),
      )
      ..addStatusListener(
        (status) {
          if (status == AnimationStatus.completed) {
            controller.reverse();
          } else if (status == AnimationStatus.dismissed) {
            controller.forward();
          }
        },
      );
    animation = CurvedAnimation(parent: controller, curve: Curves.easeInCirc);
    controller.forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    const style = TextStyle(
      fontSize: 40,
    );
    return Stack(
      children: [
        Positioned(
          right: 100,
          top: 100,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              Text(
                't: ${controller.value.toStringAsFixed(2)}',
                style: style,
              ),
              Text(
                'value: ${animation.value.toStringAsFixed(2)}',
                style: style,
              ),
              Text(
                'status: ${controller.status.name}',
                style: style,
              ),
              Text(
                'velocity: ${controller.velocity.toStringAsFixed(2)}',
                style: style,
              ),
            ],
          ),
        ),
        Align(
          alignment: AlignmentGeometry.center,
          child: Container(
            width: 100 * animation.value,
            height: 100 * animation.value,
            color: Colors.red,
          ),
        ),
      ],
    );
  }
}

【解像度 5】 応用的なアニメーションを実装する(導入)

Animatable<T>(abstract class)

image.png

0.0 ~ 1.0double 値(進行度)を受け取り、実際にアニメーションさせたい型(T)の値を出力する変換器。

値への変換処理は、内部で T evaluate(Animation<double> animation) によって行われる。

Animatable
abstract class Animatable<T> {
  // 0.0〜1.0 の入力を受け取り、対応する値を返す
  T transform(double t);

  // animation.value を使って transform() を呼ぶ
  T evaluate(Animation<double> animation) => transform(animation.value);

  // Animatable を与えられた Animation に接続し、新しい Animation を返す
  Animation<T> animate(Animation<double> parent) {
    return _AnimatedEvaluation<T>(parent, this);
  }
}

class _AnimatedEvaluation<T> extends Animation<T> with AnimationWithParentMixin<double> {
  _AnimatedEvaluation(this.parent, this._evaluatable);

  @override
  final Animation<double> parent;

  final Animatable<T> _evaluatable;

  @override
  T get value => _evaluatable.evaluate(parent);
}

image.png

Tween<T extends Object?>

image.png

AnimationContorollerdouble0.0 ~ 1.0 までの値を補間するのに対して、Tween任意のデータ型任意の範囲線形補間 することができる。

  • 任意のデータ型
    • double 以外のデータ型(ColorSize...)
Tween
final doubleTween = Tween<double>(begin: -200, end: 0);
  • 任意の範囲
    • 0.0 ~ 1.0 の範囲の double t を使用して T begin ~ T end の範囲内の値、もしくはオブジェクトに変換
Tween
final sizeTween = Tween<Size>(begin: const Size(100, 100), end: const Size(200, 200));
final colorTween = Tween<Color>(begin: Colors.red, end: Colors.blue);
0.0 ~ 1.0 の値を任意の範囲内の値に変換する
AnimationController controller = AnimationController(
  duration: const Duration(milliseconds: 500),
  vsync: this,
);
Animation<int> alpha = IntTween(begin: 0, end: 255).animate(controller);

Tween<>Animatable<T>Animation<T> とは異なる)を継承している。

AnimationControllerCurvedAnimationAnimation<double> を継承している。

Tween<T>Tdouble に限定されない。

image.png

Tween0.0 ~ 1.0 の入力値を T begin ~ T end に線形補間している

Animation vs AnimationController vs CurvedAnimation vs Tween

クラス名 継承関係 入力値 出力値 補間
Animation<T> 抽象クラス - T value
AnimationStatus status
-
AnimationController 具象クラス 時間(vsync 0.0 ~ 1.0 線形補間
CurvedAnimation 具象クラス double t 0.0 ~ 1.0(※) 非線形補間
Animatable<T> 抽象クラス double t - -
Tween<T> 具象クラス double t T begin ~ T end 線形補間

※ 一部の Curve は範囲外の値をとる

Animatable<T>Animation<T> は 双方向から接続することができる。

Animation(AnimationController, CurvedAnimation) から接続する
final animation = controller.drive(Tween(begin: 0.0, end: 100.0));
Animatable(Tween) から接続する
final animation = Tween(begin: 0.0, end: 100.0).animate(controller);

image.png

【解像度 6】 応用的なアニメーションを実装する

【実践】 double 型のアニメーション

AnimationControllerCurvedAnimationAnimation<double> を継承し、valuedouble 型の値を返却する。

Tween<T extends Object?> を用いることで double 型以外の value を利用することができる。

Screen Recording 2025-10-24 at 21.48.49.gif

ソースコード
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late Animation<Color?> animation;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    )
      ..addListener(
        () => setState(() {}),
      )
      ..addStatusListener(
        (status) {
          if (status == AnimationStatus.completed) {
            controller.reverse();
          } else if (status == AnimationStatus.dismissed) {
            controller.forward();
          }
        },
      );
    animation = ColorTween(
      begin: Colors.red,
      end: Colors.blue,
    ).animate(controller);
    controller.forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 100,
      height: 100,
      color: animation.value,
    );
  }
}

【解像度 7】 Listenable を理解する

Flutter のアニメーションはすべて、

Listenable
ChangeNotifier
AnimationController

という通知の連鎖の上に成り立っている。

AnimatedBuilderAnimatedWidget は、この通知を UI に反映するための構築パターンにすぎず、アニメーションの本質は 値の時間変化をリッスンして再描画すること と言える。

Listenable

image.png

状態の変化を「リスナ(コールバック関数)の call()」を通じて通知するための手段(インターフェース)を提供する。

  • addListener()
  • removeListener()

Listenable 自体は値を保持しておらず、値の更新通知も行わない。単一責務の原則に従い、これらは下記のクラスが担当するためである。

  • 値の保持
    • ValueListenable
      • 監視対象: T value
    • Animation
      • 監視対象: T value
  • 値の更新通知
    • ChangeNotifier
      • 通知手段: notifyListener()

image.png

ValueListenable<T>

image.png

リスナが監視する対象の T value を保持、提供する。

Animation<T>

image.png

リスナが監視する対象の T valueAnimationStatus status を保持、提供する。

Listenable から監視手段である addListener() を継承しているため 2 つの機能を併せ持つ。

  • 監視手段を提供する
    • addListener() / removeListener()
  • 監視対象を保持する
    • T value

ChangeNotifier(mixin)

image.png

監視対象の変更を検知した際に、リスナに通知する mixin

実際の動作としては、値の変更時にリスナを call() する。

ChangeNotifier
mixin class ChangeNotifier implements Listenable {

  void notifyListeners() {
    final int end = _count;
    for (int i = 0; i < end; i++) {
      _listeners[i]?.call();
    }

ValueNotifier<T>

image.png

下記の機能を併せ持った ChangeNotifier の具象クラス。

  • 監視手段を提供 する
    • addListener() / removeListener()
  • 監視対象を保持 する
    • T value
  • value 更新時に登録された listener を call() する
    • notifyListener()

setter によって value が変更されたタイミングで notifyListener() を実行する。

ValueNotifier
class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T> {
  
  @override
  T get value => _value;
  
  T _value;
  
  set value(T newValue) {
    if (_value == newValue) {
      return;
    }
    _value = newValue;
    notifyListeners(); // 👈
  }
}

ValueNotifier は「値(T value)の差し替え」を通知する仕組みであるため、値が mutable な場合でも使用できるが、推奨されない。

例えば List<int> を保持した場合、List オブジェクト自体の差し替えは検知できるが、List 内部の要素変更は検知できないためである。

監視対象は immutable 推奨
List<int> value = List.empty();

notifier.value = newList;  // 検知できる
notifier.value.add(item);  // 検知できない

AnimationController

image.png

mixinAnimationLocalListenersMixin から notifyListeners() の実装を引き継いでいる。

ValueNotifier 同様に以下の機能を併せ持つ。

  • 監視手段を提供 する
    • addListener()
  • 監視対象を保持 する
    • double valueValueNotifier では T 型)
  • value 更新時に登録された listener を call() する
    • notifyListeners()

double value が変更されたタイミングで notifyListeners() を実行する。

AnimationController
class AnimationController extends Animation<double> with AnimationLocalListenersMixin {

  @override
  double get value => _value;
  late double _value;

  set value(double newValue) {
    notifyListeners();
  }
}

AnimationControllerChangeNotifier に似た仕組みを持つが、value が時間的に連続的に変化する点が異なる。

これは内部で Ticker によって Frame ごとに value が更新されるためである。

【解像度 8】 AnimatedBuilder を理解する

ListenableBuilder

image.png

インターフェース Listenable を実装した以下の具象クラスを listenable パラメータで受け取り、listenable の通知がある度に builder 関数を実行し、UI を再描画する。

  • abstract Listenable の実装
    • mixin ChangeNotifier の実装
      • ValueNotifier
    • abstract Animation の実装
      • AnimationController
      • CurvedAnimation
        • CurvedAnimationnotifyListener() を持たない
        • notifyListener() はコンストラクタが受け取る AnimationControllerparent に依存

これらと ListenableBuilder と組み合わせて使用​​することで、Listenable のリスナが発火した際、ListenableBuilder を適用した Widget のみが再描画される。

child パラメータはオプションとなっているため、必ず必要なものではない。

child パラメータは、再描画を必要としない Widget が builder に含まれている場合に、Frame の度に再描画されることを避け、パフォーマンスを向上させるために利用される(child に渡したインスタンスが使いまわされる)。

ただし、Animation をリッスンする場合は、読みやすさを向上させるために AnimatedBuilder が推奨されている。

Although they have identical implementations, if an Animation is being listened to, consider using an AnimatedBuilder instead for better readability.
実装は同じですが、Animation をリッスンしている場合は、読みやすさを向上させるために、代わりに AnimatedBuilder を使用することを検討してください。
https://api.flutter.dev/flutter/widgets/ListenableBuilder-class.html

AnimatedBuilder

image.png

ListenableBuilder のアニメーション用。

ListenableBuilderListanable listenable は、AnimatedBuilder では Listenable animation となっているが、中の実装は同じものとなっている。)

AnimatedBuilder
class AnimatedBuilder extends ListenableBuilder {
  const AnimatedBuilder({
    required Listenable animation,
  }) : super(listenable: animation);
  
  Listenable get animation => super.listenable;

【解像度 9】 AnimatedBuilder を使う

【実践1】

Screen Recording 2025-10-26 at 11.25.58.gif

ソースコード
AnimatedBuilder
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    )
      ..addStatusListener(
        (status) {
          if (status == AnimationStatus.completed) {
            controller.reverse();
          } else if (status == AnimationStatus.dismissed) {
            controller.forward();
          }
        },
      )
      ..forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: controller,
        builder: (context, _) {
          return Container(
            width: 100 * controller.value,
            height: 100 * controller.value,
            color: Colors.red,
          );
        });
  }
}

【実践 2】 child パラメータを利用する

アニメーションする Widget 内に、アニメーションしない静的な Widget が含まれる場合、child パラメータに渡方法が有効的。

builder 関数の中では child の参照を再利用するだけで再構築は行われないため、静的要素を child に渡すとパフォーマンスが向上する。

Widget build(BuildContext context) {
  return AnimatedBuilder(
      animation: controller,
      child: const Icon( // 👈
        Icons.brush,
        size: 100,
      ),
      builder: (context, child) {
          // 👈 で渡した Widget が child に渡される 

Screen Recording 2025-10-26 at 21.37.34.gif

ソースコード
ExplicitAnimation
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    )
      ..addStatusListener(
        (status) {
          if (status == AnimationStatus.completed) {
            controller.reverse();
          } else if (status == AnimationStatus.dismissed) {
            controller.forward();
          }
        },
      )
      ..forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: controller,
        child: const Icon(
          Icons.brush,
          size: 100,
        ),
        builder: (context, child) {
          return Container(
            width: 100 * controller.value + 100,
            height: 100 * controller.value + 100,
            color: Colors.red,
            child: child,
          );
        });
  }
}

【解像度 10】 AnimatedWidget を理解して、使う

AnimatedWidget

image.png

StatefulWidget を継承した抽象クラス。

build() が実装されていないため、継承先のクラスで build() を実装する必要がある。

AnimatedWidget
abstract class AnimatedWidget extends StatefulWidget {

  // 私たちが普段よく実装しているのは、この build() ではなく _AnimatedState 内の build() です
  Widget build(BuildContext context);

  @override
  State<AnimatedWidget> createState() => _AnimatedState();
}

class _AnimatedState extends State<AnimatedWidget> {

  @override
  Widget build(BuildContext context) => widget.build(context); // 継承先の build() がここで使われる
}

// 継承先のクラス
class MyFooTransition extends AnimatedWidget {
  @override
  Widget build(BuildContext context) {
    return XXX();
  }
}

Listenable(通常は Animaiton もしくは ChangeNotifier)を受け取り、内部で setState() を呼び出すリスナを自動登録していて、これによってアニメーションが実現する。

AnimatedWidget
abstract class AnimatedWidget extends StatefulWidget {
  const AnimatedWidget({required this.listenable});

  final Listenable listenable;
}

class _AnimatedState extends State<AnimatedWidget> {

  @override
  void initState() {
    widget.listenable.addListener(_handleChange);
  }

  void _handleChange() {
    if (!mounted) return;
    setState(() {});  // 👈
  }
}

この仕組みはすでに、前述の章 で実装しており、実はそこまで難しいことをしているわけではない。

AnimatedWidgetAniamtedBuilder の builder が巨大化した来た際のリファクタリングの手段でもある。

So, we have our animation, but the build method that contains the AnimatedBuilder code is a little large. If your build method is starting to get hard to read, it’s time to refactor your code!
アニメーションは完成しましたが、AnimatedBuilderコードを含むビルドメソッドが少し大きくなっています。ビルドメソッドが読みにくくなってきたら、コードをリファクタリングするタイミングです。
https://blog.flutter.dev/when-should-i-useanimatedbuilder-or-animatedwidget-57ecae0959e8

FooTransition の実体

AnimatedWidget を継承した WidgetCustom Explicit Animation であり、Built-in Explicit Widgt の実体は AnimatedWidget のサブクラス(FooTransition)である

Built-in Explicit Animation

  • AnimatedWidget
    • SizeTransition
    • FadeTransition
    • AlignTransition
    • ScaleTransition
    • SlideTransition
    • PositionedTransition
    • DecoratedBoxTransition
    • DefaultTextStyleTransition
    • RelativePositionedTransitoin

【実践1】 Custom Explicit Animation(FooTransition)を作成する

Screen Recording 2025-10-26 at 12.43.45.gif

ソースコード
AnimatedWidget
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    )
      ..addStatusListener(
        (status) {
          if (status == AnimationStatus.completed) {
            controller.reverse();
          } else if (status == AnimationStatus.dismissed) {
            controller.forward();
          }
        },
      )
      ..forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MySizeTransition(listenable: controller);
  }
}

class MySizeTransition extends AnimatedWidget {
  const MySizeTransition({super.key, required super.listenable});

  Animation<double> get _animation => listenable as Animation<double>;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 100 * _animation.value,
      height: 100 * _animation.value,
      color: Colors.red,
    );
  }
}

【解像度 11】 異なるデータ型を扱う(double, Color

これまでの実装例では doubleColor をそれぞれ単体でアニメーションさせてきたが、両者を同時にアニメーションさせたい場合には、Animation が持つ T value ではなく、Animatableevaluate() を利用する。

Animatable
abstract class Animatable<T> {
  // 0.0〜1.0 の入力を受け取り、対応する値を返す
  T transform(double t);

  // animation.value を使って transform() を呼ぶ
  T evaluate(Animation<double> animation) => transform(animation.value);
}

実際には abstract Animatable<T> のサブクラス Tween<T> を使用する。

image.png

Tween
final opacityTween = Tween<double>(begin: 0.0, end: 1);
final sizeTween = Tween<double>(begin: 0, end: 100);

【実践】

Screen Recording 2025-10-26 at 22.11.29.gif

ソースコード
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;

  // 変更しないので、static 宣言している
  static final opacityTween = Tween<double>(begin: 0.0, end: 1);
  static final sizeTween = Tween<double>(begin: 0, end: 100);

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    )
      ..addStatusListener(
        (status) {
          if (status == AnimationStatus.completed) {
            controller.reverse();
          } else if (status == AnimationStatus.dismissed) {
            controller.forward();
          }
        },
      )
      ..forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: controller,
        builder: (context, _) {
          return Opacity(
            opacity: opacityTween.evaluate(controller), // 👈
            child: Container(
              width: sizeTween.evaluate(controller), // 👈
              height: sizeTween.evaluate(controller), // 👈
              color: Colors.red,
            ),
          );
        });
  }
}

【解像度 12】 TweenAnimationBuilder を理解して、使う

Tween<T extend Object?>

image.png

Animatable<T> を継承するクラス。

Animatableevaluate() はアニメーションの進行度 t(実体は AnimationControllerdouble value = 0.0 ~ 1.0)に対応する T value を返却する。

内部では T transoform() が利用されている。

Animatable
abstract class Animatable<T> {

  T transform(double t);

  T evaluate(Animation<double> animation) => transform(animation.value);
}

T transform(double t)Tween で実装されている。ここで呼ばれるのは lerp() である。

実装を見ると、lerp()T begin ~ T end の間の、進行度 t に応じた値を計算していることが分ける。

lerp は Linear IntERPolation(線形補間)の略語である

Tween
class Tween<T extends Object?> extends Animatable<T> {

  T lerp(double t) {
    return (begin as dynamic) + ((end as dynamic) - (begin as dynamic)) * t as T;
  }
  
  @override
  T transform(double t) {
    if (t == 0.0) {
      return begin as T;
    }
    if (t == 1.0) {
      return end as T;
    }
    return lerp(t);
  }
}

lerp() で実行されているのは以下の計算。

lerp()
T lerp(double t) {
  return (begin as dynamic) + ((end as dynamic) - (begin as dynamic)) * t as T;
}
線形補間
begin + (end - begin) * t

上記を以下の条件で計算してみる。

  • begin = 100
  • end = 200
  • t = 0.5
線形補間
100 + (200 - 100) + 0.5
= 150

ちゃんと 100 と 200 の間(進行度: 0.5)になっていることがわかった。

begin + (end - begin) * t のうちの

線形補間
(end - begin) * t

beginend の差分を進行度 t に応じて計算している箇所になっている。

T に 指定することができるクラスには、通常 staticlerp() が定義されていて、FooTween はこれを内部で利用している(というより、FooTween は、ほぼこれしか仕事をしていない)。

AlignmentTween
class AlignmentTween extends Tween<Alignment> {
  @override
  Alignment lerp(double t) => Alignment.lerp(begin, end, t)!;
}
BorderRadiusTween
class BorderRadiusTween extends Tween<BorderRadius?> {
  @override
  BorderRadius? lerp(double t) => BorderRadius.lerp(begin, end, t);
}

◯◯Tween の仕事は、変更対象のクラス <T> に定義された lerp() を呼び出すこと

Tweenmutable なオブジェクトだが、変更されない場合には static final で定義しておくと再描画の度に生成されることがない。

Tween
static final tween = Tween<double>(begin: 0.0, end: 100.0);

TweenAnimationBuilder

image.png

TweenAnimationBuilderCustom Implicit Animation であるため、AnimationController は既に内包されている。

また Flutter は Built-in Implicit Animation として以下を用意しており、TweenAnimationBuilder は主にこれらで対応できないアニメーションを作成する際に利用される。

【実践】

Screen Recording 2025-10-28 at 21.58.00.gif

ソースコード
TweenAnimationBuilder
import 'package:flutter/material.dart';

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

  @override
  State<ImplicitAnimation> createState() => _ImplicitAnimationState();
}

class _ImplicitAnimationState extends State<ImplicitAnimation> {
  bool forward = true;

  @override
  Widget build(BuildContext context) {
    return TweenAnimationBuilder(
      tween: Tween<double>(
        begin: forward ? 0.0 : 100.0,
        end: forward ? 100.0 : 0.0,
      ),
      duration: const Duration(seconds: 2),
      builder: (_, value, __) {
        return Container(
          width: value,
          height: value,
          color: Colors.red,
        );
      },
      onEnd: () => setState(() => forward = !forward),
    );
  }
}

【解像度 13】 Staggered animations

複数の異なるアニメーションが連続して起こるようなアニメーションは Staggered animations と呼ばれる。

  • 複数の Animation を使う
    • Animation では Interval を指定する
  • AnimationController は 1 つ
  • アニメーションさせるプロパティごとに Tween を作成する

image.png
https://docs.flutter.dev/ui/animations/staggered-animations

Staggered animations に必要な AnimationController は 1 つ

ParametricCurve<T>(abstract class)

0.0 ~ 1.0 の入力値(進捗 t)を受け取り、補間した出力値を返すインターフェース を持つクラス。

image.png

用途はアニメーションに限定されない。

transform() は内部で transformInternal() 使用している。transformInternal()override されることを前提とした作りになっている。

ParametricCurve
abstract class ParametricCurve<T> {

  T transform(double t) {
    return transformInternal(t);
  }

  T transformInternal(double t) {
    throw UnimplementedError();
  }
}

Curve(abstract class)

image.png

抽象クラス ParametricCurve<double> を継承した抽象クラス。

0.0 ~ 1.0 の入力値 t を別の 0.0 ~ 1.0 に変換することによって、線形補間(linear curve)を transform(double t) 内で非線形補間(non-linear curve)に変換する役割を持つ。

transformInternal(double t)override することで、カスタムのロジックを使って非線形補間(Cureve)を作成することができる。

カスタムの Cureve
class MyCurve extends Curve {

  double transform(double t) {
    return transformInternal(t);
  }

  @override
  double transformInternal(double t) =>  カスタムのロジック; // 0.0 <= t <= 1.0
}

CurvedAnimation のコンストラクタにパラメータとして渡して使用される。

Curve
CurvedAnimation(
  parent: controller,
  curve: MyCurve(), // 👈
)

Cubic

image.png

Curve を継承した具象クラス。

4 つの制御点で定義される三次ベジェ曲線を使って、滑らかな補間を行う。

  • a : 最初の制御点の x 座標
  • b : 最初の制御点の y 座標
  • c : 2 番目の制御点の x 座標
  • d : 2 番目の制御点の y 座標

Curves で多く利用されている。

Cubic
class Cubic extends Curve {
  const Cubic(this.a, this.b, this.c, this.d);

  final double a, b, c, d;

  @override
  double transformInternal(double t) {
    // ベジェ方程式の逆関数を求めて対応する y 値を返す
  }
}

Threshold

image.png

t が閾(しきい)値を超えたら 1.0 にジャンプする離散的な Curve

image.png
https://api.flutter.dev/flutter/animation/Threshold-class.html

Threshold
class Threshold extends Curve {
  const Threshold(this.threshold);

  final double threshold;

  @override
  double transformInternal(double t) {
    return t < threshold ? 0.0 : 1.0;
  }
}

Curves(abstract and utility class)

transform(double t) が生成する Curve がたくさん定義されたユーティリィティクラス(Curve のリポジトリ)。

Screenshot 2025-10-19 at 15.19.35.png
https://api.flutter.dev/flutter/animation/Curves-class.html

Interval

image.png

開始点(t = 0.0)からある時点までは 0.0 を返し、終了点(t = 1.0)より前に 1.0 に到達しその後 1.0 を返し続ける Curve。

image.png
https://api.flutter.dev/flutter/animation/Curves-class.html

アニメーションを遅延させたりする際に利用できる。

Curve vs Cubic vs Threshold vs Curves vs Interval

CubicThresholdInterval は全て Curve である

image.png

CurvedController vs CurvedAnimation vs Curve

AnimationController が保持するアニメーションの進行度 t0.0 ~ 1.0)は Curve によって補間され、Widget(UI)に伝達される。

image.png

CurvedAnimation
class CurvedAnimation extends Animation<double> with AnimationWithParentMixin<double> {
  CurvedAnimation({
    required this.parent,
    required this.curve,
  });

  final Animation<double> parent;
  Curve curve;
}
animation に controller, curve が渡される
controller = AnimationController(
  duration: const Duration(seconds: 2),
  vsync: this,
);

animation = CurvedAnimation(
  parent: controller, // controller
  curve: Curves.easeInCirc // curve
);
CurvedAnimation.transform()
class CurvedAnimation extends Animation<double> with AnimationWithParentMixin<double> {

  final Animation<double> parent;
  Curve curve;
  
  double get value {
    final double t = parent.value;
    curve.transform(parent.value);
  }
}

【実践】 Interval を使って Staggered animations を実装する

Screen Recording 2025-10-30 at 22.47.12.gif

ソースコード
Staggered animations
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late Animation<double> size;
  late Animation<double> opacity;
  late Animation<Color?> color;
  late Animation<BorderRadius> borderRadius;

  static final sizeTween = Tween<double>(begin: 100.0, end: 200.0);
  static final opacityTween = Tween<double>(begin: 0.0, end: 1.0);
  static final colorTween = ColorTween(begin: Colors.red, end: Colors.blue);
  static final borderRadiusTween = Tween<BorderRadius>(
    begin: const BorderRadius.all(Radius.circular(0.0)),
    end: const BorderRadius.all(Radius.circular(50.0)),
  );

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      reverseDuration: const Duration(seconds: 2),
      vsync: this,
    )
      ..addStatusListener(
        (status) {
          if (status == AnimationStatus.completed) {
            controller.reverse();
          } else if (status == AnimationStatus.dismissed) {
            controller.forward();
          }
        },
      )
      ..forward();

    size = sizeTween.animate(
      CurvedAnimation(
        parent: controller,
        curve: const Interval(
          0.0,
          0.25,
        ),
      ),
    );
    opacity = opacityTween.animate(
      CurvedAnimation(
        parent: controller,
        curve: const Interval(
          0.25,
          0.5,
        ),
      ),
    );
    color = colorTween.animate(
      CurvedAnimation(
        parent: controller,
        curve: const Interval(0.50, 0.75),
      ),
    );
    borderRadius = borderRadiusTween.animate(
      CurvedAnimation(
        parent: controller,
        curve: const Interval(0.75, 1.0),
      ),
    );
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: controller,
      builder: (context, _) {
        return Opacity(
          opacity: opacity.value,
          child: Container(
            width: size.value,
            height: size.value,
            decoration: BoxDecoration(
              color: color.value,
              borderRadius: borderRadius.value,
            ),
          ),
        );
      },
    );
  }
}

【解像度 14】 TweenSequence を理解して、使う

TweenSequence<T>

image.png

複数の Tween を連結して 1 つの Animation として扱えるようにするクラス。

これにより「最初は拡大」「次に縮小」といった 段階的な動きを 1 つの AnimationController で自然に表現できる。

コンストラクタは List<TweenSequenceItem<T>> を受け取る。

final TweenSequence<double> serquence = TweenSequence<double>(
  <TweenSequenceItem<double>>[ ],
);

image.png

TweenSequenceItemweight に指定した値が duraton に対する割合になる(順番はリスト順)。

weight相対的な割合 を示す。

例えば [4, 4, 2] とすれば、全体を 100% としたときの、それぞれが 40% : 40% : 20% の時間配分として扱われる。

TweenSequence
final tween = TweenSequence<double>([
  TweenSequenceItem(
    tween: Tween(begin: 0.0, end: 100.0),
    weight: 40.0, // 0 ~ 40%
  ),
  TweenSequenceItem(
    tween: Tween(begin: 100.0, end: 50.0),
    weight: 40.0, // 40 ~ 80%
  ),
  TweenSequenceItem(
    tween: Tween(begin: 50.0, end: 200.0),
    weight: 20.0, // 80 ~ 100%
  ),
]);

image.png

【実践】 TweenSequence を使って実装する

これまでは「アニメーションが完了したら逆再生する」といった制御を、
addStatusListener()AnimationStatus を監視して forward()reverse() を呼び出す必要があった。

TweenSequence を使うと、1つの AnimationController で往復や段階的な動きを自然に表現できるため、これらのリスナによる制御が不要になる。

Screen Recording 2025-11-01 at 6.27.19.gif

ソースコード
import 'package:flutter/material.dart';

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

  @override
  State<ExplicitAnimation> createState() => _ExplicitAnimationState();
}

class _ExplicitAnimationState extends State<ExplicitAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late Animation<double> animation;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    )..repeat();

    animation = TweenSequence<double>(
      <TweenSequenceItem<double>>[
        TweenSequenceItem<double>(
          tween: Tween<double>(begin: 0.0, end: 100.0),
          weight: 50.0,
        ),
        TweenSequenceItem<double>(
          tween: Tween<double>(begin: 100.0, end: 0.0),
          weight: 50.0,
        ),
      ],
    ).animate(controller);
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: animation,
        builder: (context, _) {
          return Container(
            width: animation.value,
            height: animation.value,
            color: Colors.red,
          );
        });
  }
}

【解像度 15】 Ticker を理解する

AnimationController のコンストラクタには、必ず TickerProvider オブジェクトを渡さないといけない。

渡された TickerProviderTicker を生成して、AnimationController に提供する。

Tikcer はアニメーションに対して「時間のカチカチ(tick)」を提供する。

vsync

AnimationController のコンストラクタ引数。

vsync
controller = AnimationController(
  vsync: this, // 👈 this が TickerProvider
  duration: const Duration(seconds: 2),
);

Vertical Synchronization の略。

画面の Frame とアニメーションの更新を同期させるという意味。

TickerProvidermixin で State に対してミックスインされる。Flutter は State が画面上に見えている間だけ Ticker を有効化する。

Ticker

image.png

Frame ごとにコールバック関数を呼び出す オブジェクト。

Ticker
final ticker = Ticker((elapsed) {
  // 処理 (elapsed は経過時間)
});
ticker.start()

start() によって有効化される(生成されるのみでは有効化されない)。

dispose() 処理を必要とする(dispose() しないと、延々とコールバック関数が呼ばれ続ける)。

muted フラグを OFF することで、一時停止が可能。

ミュート中、コールバック関数は実行されない状態となる。

Ticker
class Ticker {
  
  bool get muted => _muted;
  bool _muted = false;

  set muted(bool value) {
    if (value == muted) {
      return;
    }
    _muted = value;
    if (value) {
      unscheduleTick();
    } else if (shouldScheduleTick) {
      scheduleTick();
    }
  }
}

start()stop()AnimationController によって利用される。

mutedTickerProvier によって利用される。

内部では SchedulerBinding.instance.scheduleFrameCallback() が利用されている。

Ticker
class Ticker {

  void scheduleTick({bool rescheduling = false}) {
    assert(!scheduled);
    assert(shouldScheduleTick);
    _animationId = SchedulerBinding.instance.scheduleFrameCallback(
      _tick,
      rescheduling: rescheduling,
    );
  }
}

TickerProvider(abstract class)

image.png

Ticker のファクトリ。

Ticker は直接インスタンス化もできるが、通常は TickerProvider を介して利用される(主に利用するのは AnimationController)。

SingleTickerProviderStateMixin<T extends StatefulWidget>(mixin)

image.png

1 つの Ticker をもつ Statevsync を提供する。

SingleTickerProviderStateMixin
mixin SingleTickerProviderStateMixin<T extends StatefulWidget> on State<T>
    implements TickerProvider {

  Ticker? _ticker;

  @override
  Ticker createTicker(TickerCallback onTick) {
    _ticker = Ticker(onTick);
    return _ticker!;
  }

Ticker を管理する手間を省いてくれる。

StatefulWidgetStatemixin すると、AnimationControllerTicker を要求できるようになる。

TickerProviderStateMixin<T extends StatefulWidget>(mixin)

複数の Ticker をもつ State に vsync を提供する。

【デバッグ】

RepaintBoundary

Flutter が用意するデバッグ用のフラグ debugRepaintRainbowEnable を ON にすることで、アニメーションの最中にどの Frame が更新されているかが緑色で囲まれるため、視覚的に不要な領域が更新されていないかを確認することができる。

RepaintBoudary
void main() {
    debugRepaintRainbowEnable = true;
    runApp(MyApp());
}

アニメーション中に変化しない Widget が、この緑の領域に含まれていた場合、余分な Frame 処理のためのオーバーヘッドが発生していることを意味する。

Flutter 3.0 付近で削除された API です

Flutter 3.35.6 では DevTools に内包されていた。

Screenshot 2025-11-01 at 6.45.53.png

Screen Recording 2025-11-01 at 6.48.30.gif

これをみると、アニメーション中赤い四角形だけでなく、画面全体が再描画されてしまっていた。

RepaintBoundary ウィジェットでラップすると再描画の対象が最適化されるとのことだったが、緑の枠線が二重で表示されるようになった。

Screen Recording 2025-11-01 at 6.55.49.gif

最適化は child パラメータを意識しておくと良さそう。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?