🗂

widgetが切り替わった際の他のwidgetの位置アニメーション実現

に公開

この記事で解説すること

  • 目的:Columnなどで複数のWidgetが縦に並んでいる場合、
    その中の1つが切り替わったときに、隣接するWidgetとの間隔変化を滑らかにアニメーションさせる方法。
  • 使用する主なアニメーションWidget
    • AnimatedSize(高さ変化を滑らかに)
    • AnimatedSwitcher(コンテンツ入れ替え時の演出)
    • AnimatedSlide / AnimatedAlign(位置をずらす)
  • 応用:ボタン列や補助UIを「自然に」動かす/「明示的に」動かす選択肢

心構え

切り替えアニメーションは、動かす対象ごとに分解すると考えやすくなります。

  • 高さ変化AnimatedSize
    切り替えによる全体レイアウトのガクつきを防ぐ
  • 中身の入れ替えAnimatedSwitcher
    Fade / Slide / Scale の組み合わせで滑らかに
  • 周辺UIの位置
    • 自然に追従 → 上記 AnimatedSize に任せる+軽くフェードやスライド
    • 明示的に移動 → AnimatedSlideAnimatedAlign でコントロール
  • 方向感を出す
    prevIndex を保持し (currentIndex - prevIndex) の符号で左右・上下方向を決定

完成物

sample

実装手順

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