🗂
widgetが切り替わった際の他のwidgetの位置アニメーション実現
この記事で解説すること
-
目的:Columnなどで複数のWidgetが縦に並んでいる場合、
その中の1つが切り替わったときに、隣接するWidgetとの間隔変化を滑らかにアニメーションさせる方法。 -
使用する主なアニメーションWidget:
-
AnimatedSize(高さ変化を滑らかに) -
AnimatedSwitcher(コンテンツ入れ替え時の演出) -
AnimatedSlide/AnimatedAlign(位置をずらす)
-
- 応用:ボタン列や補助UIを「自然に」動かす/「明示的に」動かす選択肢
心構え
切り替えアニメーションは、動かす対象ごとに分解すると考えやすくなります。
-
高さ変化 →
AnimatedSize
切り替えによる全体レイアウトのガクつきを防ぐ -
中身の入れ替え →
AnimatedSwitcher
Fade / Slide / Scale の組み合わせで滑らかに -
周辺UIの位置 →
- 自然に追従 → 上記
AnimatedSizeに任せる+軽くフェードやスライド - 明示的に移動 →
AnimatedSlideやAnimatedAlignでコントロール
- 自然に追従 → 上記
-
方向感を出す →
prevIndexを保持し(currentIndex - prevIndex)の符号で左右・上下方向を決定
完成物

実装手順
1. 状態を定義する
int sectionIndex; // 現在のセクション
int prevIndex; // 直前のセクション
int dir = (sectionIndex - prevIndex) >= 0 ? 1 : -1; // 切替方向
2. 動的領域をラップする
- Column の中で切り替え対象を AnimatedSize で囲む
推奨:alignment: Alignment.topCenter(上下のUIが自然に追従)
3. 中身を入れ替える
- AnimatedSwitcher の transitionBuilder で演出を設定
- ValueKey を必ず付与(キーなしだとWidgetが再利用されアニメが発火しない)
AnimatedSwitcher(
duration: Duration(milliseconds: 380),
transitionBuilder: (child, animation) {
final slide = Tween(
begin: Offset(dir * 0.06, 0),
end: Offset.zero
).animate(animation);
final fade = CurvedAnimation(parent: animation, curve: Curves.easeOut);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: slide, child: child),
);
},
child: childWidget, // 必ず ValueKey を付ける
);
4. ボタン列をアニメーション
- 自然に動かす → 上記 AnimatedSize に任せつつ軽くフェードやスライド
- 明示的に動かす → AnimatedSlide / AnimatedAlign で位置オフセットを設定
サンプルコード
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
/// 動作確認用のメインアプリ
class MyApp extends StatefulWidget {
const MyApp({super.key});
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
int sectionIndex = 0;
void setSection(int index) {
setState(() => sectionIndex = index);
}
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Animated Column Example')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SectionSwitcher(
index: sectionIndex,
builder: (i) => switch (i) {
0 => const _SimpleBox(
key: ValueKey('c0'),
color: Colors.blue,
text: 'Simple Carousel',
height: 120,
),
1 => const _SimpleBox(
key: ValueKey('c1'),
color: Colors.green,
text: 'NoButton Carousel',
height: 200,
),
2 => const _SimpleBox(
key: ValueKey('c2'),
color: Colors.orange,
text: 'Rich Carousel',
height: 160,
),
_ => const SizedBox(),
},
),
const SizedBox(height: 16),
AnimatedButtonRow(
index: sectionIndex,
buttonsBuilder: (_) => [
ElevatedButton(
onPressed: () => setSection(0),
child: const Text('Simple'),
),
ElevatedButton(
onPressed: () => setSection(1),
child: const Text('NoButton'),
),
ElevatedButton(
onPressed: () => setSection(2),
child: const Text('Rich'),
),
],
),
],
),
),
),
),
);
}
}
/// 高さ変化 + 中身入替の本体
class SectionSwitcher extends StatefulWidget {
final int index;
final Widget Function(int index) builder;
final Duration duration;
final double slideFraction;
const SectionSwitcher({
super.key,
required this.index,
required this.builder,
this.duration = const Duration(milliseconds: 380),
this.slideFraction = 0.06,
});
State<SectionSwitcher> createState() => _SectionSwitcherState();
}
class _SectionSwitcherState extends State<SectionSwitcher> {
int _prev = 0;
void didUpdateWidget(covariant SectionSwitcher oldWidget) {
if (oldWidget.index != widget.index) {
_prev = oldWidget.index;
}
super.didUpdateWidget(oldWidget);
}
Widget build(BuildContext context) {
final dir = (widget.index - _prev) >= 0 ? 1 : -1;
final child = widget.builder(widget.index);
return AnimatedSize(
duration: widget.duration,
curve: Curves.easeInOutCubic,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: widget.duration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: (current, previous) => Stack(
alignment: Alignment.center,
children: [...previous, if (current != null) current],
),
transitionBuilder: (child, animation) {
final isOut = animation.status == AnimationStatus.reverse;
final begin = Offset((isOut ? -dir : dir) * widget.slideFraction, 0);
final slide = Tween(begin: begin, end: Offset.zero)
.chain(CurveTween(curve: Curves.easeOutCubic))
.animate(animation);
final fade =
CurvedAnimation(parent: animation, curve: Curves.easeOut);
final scale = Tween(begin: 0.98, end: 1.0)
.chain(CurveTween(curve: Curves.easeOutCubic))
.animate(animation);
return FadeTransition(
opacity: fade,
child: SlideTransition(
position: slide,
child: ScaleTransition(scale: scale, child: child),
),
);
},
child: child,
),
);
}
}
/// ボタン列の軽いアニメーション
class AnimatedButtonRow extends StatelessWidget {
final int index;
final List<Widget> Function(int index) buttonsBuilder;
const AnimatedButtonRow({
super.key,
required this.index,
required this.buttonsBuilder,
});
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 240),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final slide = Tween(begin: const Offset(0, 0.04), end: Offset.zero)
.chain(CurveTween(curve: Curves.easeOutCubic))
.animate(animation);
final fade = CurvedAnimation(parent: animation, curve: Curves.easeOut);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: slide, child: child),
);
},
child: Row(
key: ValueKey('buttons-$index'),
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttonsBuilder(index),
),
);
}
}
/// デモ用のシンプルなBox
class _SimpleBox extends StatelessWidget {
final Color color;
final String text;
final double height;
const _SimpleBox({
super.key,
required this.color,
required this.text,
required this.height,
});
Widget build(BuildContext context) {
return Container(
height: height,
width: double.infinity,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: Text(
text,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
),
),
);
}
}
注意点・コツ
安定キー必須:切り替えchildにはValueKeyを付ける
高さアニメは範囲制限:ConstrainedBox等で最大高さを設定するとレイアウト崩れを防げる
画像のチラつき回避:BoxFitと固定アスペクト比、プレースホルダーで初期レイアウトを安定
Duration / Curve の統一感:本体はやや長め、補助UIは短めで
IntrinsicHeightは避ける:処理負荷が高く、AnimatedSizeの軽快さが損なわれる
方向感を出す:prevIndex保持+差分計算でスライド方向を決定
Discussion