FlutterのAnimationを実装する時に開いておくzennを書く
モチベーション
FlutterのAnimation周りを実装する時になると、Animated系って...Transition系って...AnimationControllerってどうやって...となるのでまとまった記事が欲しくなりました。
随時更新していきますが、まとめていきます。
仕組みから説明している部分もありますが、なるべく実際に実装する時に開いて置ける記事を目指しています。
ImplicitlyAnimatedWidgetのサブクラス(Animated系)
最もシンプルにアニメーションを実現できますが、できることもシンプルです。
Implicitlyとは「暗黙的な」という意味があり、その名の通り明示的にアニメーションのスタートやストップを記述する必要がありません。
ImplicitlyAnimatedWidgetのプロパティを見るとわかりますが、アニメーションを調整できるのは以下の3つです
- curve
- duration(required)
- onEnd
どんな種類があるかは公式を見ればわかります。
ImplicitlyAnimatedWidgetのサブクラスを自作する
Flutterが用意してくれているAnimated系に仕様を満たすものがない場合は自作するという手もありますが、ある程度内部実装を理解していた方がスムーズに実装できます。
ImplicitlyAnimatedWidget
はStatefulWidget
なのでState<T>
のサブクラスも必要になりますが、その対象はImplicitlyAnimatedWidgetState
と決められています。
abstract class ImplicitlyAnimatedWidget extends StatefulWidget {
...
ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState();
...
}
ImplicitlyAnimatedWidgetState
の中身を見てみます。
特徴としてはAnimationController
が使われているのとforEachTween
の実装が必要になっているということです。
abstract class ImplicitlyAnimatedWidgetState<T extends ImplicitlyAnimatedWidget> extends State<T> with SingleTickerProviderStateMixin<T> {
AnimationController get controller => _controller;
late final AnimationController _controller = AnimationController(
duration: widget.duration,
debugLabel: kDebugMode ? widget.toStringShort() : null,
vsync: this,
);
void forEachTween(TweenVisitor<dynamic> visitor);
このforEachTween
をどう実装すればいいかですが、説明にわかりやすいAnimatedPositioned
のStateである_AnimatedPositionedState
を見てみます。
まずはforEachTween
以外の部分で、build
メソッドですがどうやら単にPositioned
を返しているだけで、そのプロパティはTween
のevaluate
からとってきています。
ちなみにevaluate
の引数のanimation
はImplicitlyAnimatedWidgetState
が持っているAnimationContrller
です。
class _AnimatedPositionedState extends AnimatedWidgetBaseState<AnimatedPositioned> {
Tween<double>? _left;
Tween<double>? _top;
Tween<double>? _right;
Tween<double>? _bottom;
Tween<double>? _width;
Tween<double>? _height;
Widget build(BuildContext context) {
return Positioned(
left: _left?.evaluate(animation),
top: _top?.evaluate(animation),
right: _right?.evaluate(animation),
bottom: _bottom?.evaluate(animation),
width: _width?.evaluate(animation),
height: _height?.evaluate(animation),
child: widget.child,
);
}
...
}
次にforEachTween
を見てみると、ここでそれぞれのTween<double>
に値を代入しています。
void forEachTween(TweenVisitor<dynamic> visitor) {
_left = visitor(_left, widget.left, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
_top = visitor(_top, widget.top, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
_right = visitor(_right, widget.right, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
_bottom = visitor(_bottom, widget.bottom, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
_width = visitor(_width, widget.width, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
_height = visitor(_height, widget.height, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
}
visitor
の引数には
- 代入するTween
- アニメーション後の値となっていて欲しい値
- 「Tweenのbeginを引数の
value
で生成したインスタンス」を返すメソッド
を渡しています。
こうして、AnimatedPositioned(left: 値)
の値の部分が変化すれば自動でアニメーションしてくれるWidgetの完成です。
forEachTween
が呼ばれるのはinitState
とdidUpdateWidget
の2箇所で呼ばれます。
アニメーションが起こるのはdidUpdateWidget
の方で、Widgetが更新されアニメーションの起動が必要であればforward()
が呼ばれます。
ただし、AnimatedContainer
を見るとわかりますが、ImplicitlyAnimatedWidgetState
ではなく、さらにそのサブクラスのAnimatedWidgetBaseState
が使われています。
このAnimatedWidgetBaseState
はAnimationController
の値が変化したらsetState
するという違いだけです。
abstract class AnimatedWidgetBaseState<T extends ImplicitlyAnimatedWidget> extends ImplicitlyAnimatedWidgetState<T> {
void initState() {
super.initState();
controller.addListener(_handleAnimationChanged);
}
void _handleAnimationChanged() {
setState(() { /* The animation ticked. Rebuild with new animation value */ });
}
}
AnimatedWidgetのサブクラス(Transition系)
ImplicitlyAnimatedWidgetのサブクラスであるAnimated系では実現できないものはこのTransition系か、後に記述するAnimatedBuilder
で実現できないかを考えるのがよのかなと思います。
どんなTransition系があるかは公式に書いてあります。
例えばSizeTransition
は以下のようにして使います。
ポイントは
-
SingleTickerProviderStateMixin
を継承したクラス - initStateで
AnimationController
を作成 - 引数の
Animation
(今回はsizeFactor
)に、Animation
を渡す。
class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
AnimationController _controller;
Animation<double> _animation;
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.fastOutSlowIn,
);
}
void dispose() {
_controller.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('SizeTransition Example'),
),
body: Center(
child: SizeTransition(
sizeFactor: _animation,
axis: Axis.vertical, // 縦方向のサイズ変更を行います
axisAlignment: -1.0,
child: Container(
width: 200,
height: 200,
color: Colors.blue,
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
if (_controller.isCompleted) {
_controller.reverse();
} else {
_controller.forward();
}
},
child: Icon(Icons.play_arrow),
),
),
);
}
}
Transition系の自作
Transition系ももちろん自作ができます。そのために抽象クラスのAnimatedWidget
を見てみます。
AnimatedWidget
もStatefulWidget
のサブクラスで、Listenable
を引数に受け取ります。
abstract class AnimatedWidget extends StatefulWidget {
const AnimatedWidget({
super.key,
required this.listenable,
});
/// The [Listenable] to which this widget is listening.
///
/// Commonly an [Animation] or a [ChangeNotifier].
final Listenable listenable;
...
}
AnimatedWidget
のState
である_AnimatedState
のinitState
を見ると、listenしており、その中ではsetState
をしているだけです。
class _AnimatedState extends State<AnimatedWidget> {
void initState() {
super.initState();
widget.listenable.addListener(_handleChange);
}
void _handleChange() {
setState(() {
// The listenable's state is our build state, and it changed already.
});
}
...
つまりAnimatedWidget
渡しているListenable
(実際はAnimation
を渡すことが多い)に変化があればリビルドしていることになります。
Animation(Controller)
Transition系やAnimatedBuilder
を使おうと思うとAnimation
クラスやそのサブクラスのAnimationController
の理解が必要です。
Animation
を直接作成することは少なく、AnimationController
を先に作り、drive
メソッドなどとTween
で必要なAnimation
クラスのインスタンスを作成すると思います。
AnimationController
はその名の通りアニメーションを操作するもので、forward
やreverse
メソッドでアニメーションを開始したりするほか、.value
に値を渡すことでもアニメーションを起動させることができます。
基本的に0.0
から1.0
の値を保持しており、forward
でアニメーションを開始するとduration
で指定した時間をかけて0.0
→1.0
へと値が変化していきます。
Transition系やAnimatedBuilder
と使うケースが多いですが、addListner
の中でsetState
をすれば、これだけでアニメーションは実現できます。
late AnimationController controller;
late Animation<Alignment> animation;
final easeOut = CurveTween(curve: Curves.easeOut);
late Animation<Color?> _iconColor;
void initState() {
controller = AnimationController(
duration: Duration(seconds: 3),
vsync: this
);
controller = AnimationController(
duration: Duration(seconds: 3),
vsync: this
)..addListener(() {
setState(() {});
});
animation = controller.drive(easeOut);
_iconColor = _controller.drive(_iconColorTween.chain(_easeInTween));
}
// widget
Widget build(BuildContext context) {
return Align(
alignment: animation.value,
child: Text('Hello world!'),
);
}
Tween
AnimationController
は0.0
から1.0
の値を保持していると述べましたが、例えばColor
を赤から青にゆっくり変化させたい時に数値では不可能です。
なので0.0
から1.0
の変化を赤
から青
の変化に変えてくれるのがColorTween
(Tween
のサブクラス)です。
Animation
の.drive
メソッドなどと一緒に使うケースが多いかと思います。
final Animation animation = _animationController.drive(
AlignmentTween(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
);
同じことをしていますが、Tween
の.animate
メソッドからもAnimation
を作ることができます。
final Animation<Alignment> animation = AlignmentTween(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).animate(_animationController);
evaluateメソッド
Tween
の.evaluate
メソッドもたまに使うので書いておきます。
とは言っても、単純に値を返してくれるメソッドで、例えば以下の例がわかりやすいかと思います(ChatGPTに聞いた)
// Tweenの定義: 0から100までの数値を補間します。
final Tween<double> tween = Tween<double>(begin: 0.0, end: 100.0);
// AnimationControllerの定義: アニメーションの制御を行います。
final AnimationController controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this, // vsyncはTickerProviderのインスタンスを指します。
);
// Animationオブジェクトの生成: TweenとAnimationControllerを組み合わせます。
final Animation<double> animation = controller.drive(tween);
// アニメーションの進行に合わせてTweenの値を評価します。
// ここでは、controllerの値が0.5(中間点)の場合の例を示します。
final double value = tween.evaluate(animation);
// この場合、`value`は50.0になります(0.0から100.0の中間値)。
chainメソッド
2つのTweenを繋げることができます。
SizeTween
とCurveTween
を組み合わせた例です。
_animation = SizeTween(
begin: Size(100, 100), // 開始サイズ
end: Size(200, 200), // 終了サイズ
).chain(
CurveTween(curve: Curves.easeInOut), // カーブを適用
).animate(_controller);
ちなみに、.chain
を使わなくても.drive
で書くこともできます。
_animation = _controller.drive(
CurveTween(curve: Curves.easeInOut), // カーブを適用
).drive(
SizeTween(
begin: Size(100, 100), // 開始サイズ
end: Size(200, 200), // 終了サイズ
),
);
Interval
AnimationController
は0.0→1.0の変化ですが、例えば途中から動き出して欲しいなどの使用の場合は0.5→1.0の値の変化のにアニメーションさせたい場合はInterval
を使います。
AnimationController controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
);
// 50%から始まり、アニメーション終了時に完了するアニメーション
Animation<double> animation = Tween(begin: 0.0, end: 1.0)
.animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.5, 1.0, // ここで、アニメーションの時間区間を指定します
curve: Curves.easeInOut, // この区間に適用するカーブ
),
),
);
TweenSequence
一つのアニメーションの中に複数の変化を入れたい時、例えば
- 最初はeaseinだけど途中からはease out
- 一度の
forward
で大きくなるが最終的には小さくなるContainer
などを実現できます。
final AnimationController controller = AnimationController(
duration: const Duration(seconds: 3),
vsync: this,
);
final Animation<double> animation = TweenSequence<double>([
TweenSequenceItem(
tween: Tween<double>(begin: 0.0, end: 100.0)
.chain(CurveTween(curve: Curves.easeIn)),
weight: 50.0,
),
TweenSequenceItem(
tween: ConstantTween<double>(100.0),
weight: 20.0,
),
TweenSequenceItem(
tween: Tween<double>(begin: 100.0, end: 0.0)
.chain(CurveTween(curve: Curves.easeOut)),
weight: 30.0,
),
]).animate(controller);
おわりに
ちょっと長くなってしまいましたが、疲れたのでここら辺で一旦終わりたいと思います。
SpringAnimation等、もうちょい書きたいことがあるので今度本として書くかもしれないです。
その時はまた読んでいただけると嬉しいです。
Discussion