🔖

ReorderableListViewにアニメーションを付ける

2024/08/15に公開

ReorderableListViewにアニメーションを付ける

娘「Flutterって便利だけどアニメーションが分かりにくいのよね」
私「じゃあ、最近、ReorderableListViewに追加や削除の時にアニメーションするコードを書いたから、それを使って勉強しよう」

1. ベースのReorderableListView

私「Web上で動かせるコードを置いたから下記のリンクをクリックしてみて」
Basic ReorderableListView
reorderable

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

void main() => runApp(const ReorderableApp());

class ReorderableApp extends StatelessWidget {
  const ReorderableApp({super.key});
  
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: ReorderableListViewExample(),
    );
  }
}

class ReorderableListViewExample extends StatefulWidget {
  const ReorderableListViewExample({super.key});
  
  State<ReorderableListViewExample> createState() =>
      _ReorderableListViewExampleState();
}

class _ReorderableListViewExampleState
    extends State<ReorderableListViewExample> {
  final List<String> _items = List.generate(10, (i) => '${i + 1}');

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: ReorderableListView(
        onReorder: (oldIndex, newIndex) {
          setState(() {
            if (newIndex > oldIndex) {
              newIndex -= 1;
            }
            final String item = _items.removeAt(oldIndex);
            _items.insert(newIndex, item);
          });
        },
        children: <Widget>[
          for (int i = 0; i < _items.length; ++i)
            ListTile(
              key: Key('$i'),
              title: Text('Item ${_items[i]}'),
            )
        ],
      ),
    );
  }
}

娘「これなに?」

final List<String> _items = List.generate(10, (i) => '${i + 1}');

私「これは1から10まで10個の数字のリストを作る書き方だよ。_itemsの中身は[1,2,3,4,5,6,7,8,9,10]になる」

娘「じゃあこれは?」

[
  for (int i = 0; i < _items.length; ++i)
    ListTile(
      key: Key('$i'),
      title: Text('Item ${_items[i]}'),
    ),
]

私「これもリストを作成する書き方。_itemsと同数のListTileのリストを作ってる」
娘「さっきのと何が違うの?」
私「機能的には違わない。for文に慣れている人に分かりやすいってぐらいかな」

娘「この部分が分からないんだけど」

onReorder: (int oldIndex, int newIndex) {
  setState(() {
    if (oldIndex < newIndex) {
      newIndex -= 1;
    }
  final int item = _items.removeAt(oldIndex);
    _items.insert(newIndex, item);
  });
},

私「これはリストを動かしたときに_itemsを更新する処理。oldIndexからnewIndexにリストを移動させるんだ。newIndex -= 1の部分は、リストを上に動かしたのか下に動かしたかで挿入位置を変える必要があって、その調整。ロジックを細かく追えばわかると思うけど、ここは定型的な部分だからそういうものかと思っておけば問題ない」

2. 削除アニメーション

私「まず削除の機能を付けてみよう。ScaffoldのappBarを設定して削除ボタンを配置するよ」
Removable ReorderableListView
remove

ソースコード
    return Scaffold(
      appBar: AppBar(
        title: const Text('Reorderable ListView'),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete),
            onPressed: () {
              setState(() {
                _items.removeAt(1);
              });
            },
          ),
        ],
      body: ...

私「ゴミ箱アイコンをクリックすると二行目が削除されるけど、アニメーションはなし」
娘「それは分かったけど、さっきからsetState()って出てくるけどこれなに?」
私「画面を書き直す必要がある処理をするときはsetState()の中に書く決まり。そうしておくと自動的にbuild()が呼ばれて画面が再構築されるんだ。この中で_items.removeAt(1)してるから二行目が削除されるわけ」
娘「わかった」

私「じゃあ、まず、削除する行を右にスライドするアニメーションを付けてみよう」
Animated Removable ReorderableListView
remove anime

ソースコード
class _ReorderableListViewExampleState extends State<ReorderableListViewExample>
    with SingleTickerProviderStateMixin {
  late final AnimationController animationController;
  late final Animation<Offset> slideOutAnimation;
  final Animation<Offset> stopAnimation =
      const AlwaysStoppedAnimation(Offset(0, 0));
  final List<String> _items = List.generate(10, (i) => '${i + 1}');
  int _fromIndex = -1;

  
  void initState() {
    super.initState();
    animationController = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 300));
    slideOutAnimation =
        Tween<Offset>(begin: Offset.zero, end: const Offset(1.0, 0.0))
          .animate(CurvedAnimation(
            parent: animationController,
            curve: Curves.easeInOut,
    ));
  }

  
  void dispose() {
    animationController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Reorderable ListView'),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete),
            onPressed: () {
              _fromIndex = 1;
              setState(() {
                animationController.reset();
              });
              WidgetsBinding.instance.addPostFrameCallback((_) {
                void listener(AnimationStatus status) {
                  if (status == AnimationStatus.completed) {
                    animationController.removeStatusListener(listener);
                    setState(() {
                      _items.removeAt(_fromIndex);
                    });
                    _fromIndex = -1;
                  }
                };
                animationController.addStatusListener(listener);
                animationController.forward();
              });
            },
          ),
        ],
      ),
      body: ReorderableListView(
        onReorder: (oldIndex, newIndex) {
          setState(() {
            if (newIndex > oldIndex) {
              newIndex -= 1;
            }
            final String item = _items.removeAt(oldIndex);
            _items.insert(newIndex, item);
          });
        },
        children: <Widget>[
          for (int i = 0; i < _items.length; ++i)
            SlideTransition(
              key: Key('$i'),
              position: _fromIndex == i ? slideOutAnimation : stopAnimation,
              child: ListTile(title: Text('Item ${_items[i]}')))
        ],
      ),
    );
  }
}

娘「なんかずいぶん増えたね」
私「まだ序の口、順番に説明するね」

準備

class _ReorderableListViewExampleState extends State<ReorderableListViewExample>
    with SingleTickerProviderStateMixin {
  late final AnimationController animationController;
  late final Animation<Offset> slideOutAnimation;
  final Animation<Offset> stopAnimation =
      const AlwaysStoppedAnimation(Offset(0, 0));

私「with SingleTickerProviderStateMixinってのが増えてるけど、これはアニメーションに関するイベントを受け取るための準備。これをしないと通知が来ないらしい」
娘「Singleって付いているけどSingleじゃないのもあるの?」
私「ある。複数のanimationControllerが必要な時はそちらを使うらしい。今回の場合Singleなしでも動くけど、処理効率の問題かもしれないね」

私「animationControllerはアニメーションの開始などを制御するオブジェクトを格納する変数。slideOutAnimationは右にスライドするアニメーションを格納する変数、stopAnimationは動かないアニメーションを格納する変数。finalってのは一度格納したら変更しないよって宣言。lateは後で設定するねって宣言」

  
  void initState() {
    super.initState();
    animationController = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 300));
    slideOutAnimation =
        Tween<Offset>(begin: Offset.zero, end: const Offset(1.0, 0.0))
          .animate(CurvedAnimation(
            parent: animationController,
            curve: Curves.easeInOut,
    ));
  }

私「initState()はclassを初期化する処理だから、その中で用意した変数に値を代入している。AnimationControllerの生成でvsync: thisってなってるけど、ここにthisを書けるのはwith SingleTickerProviderStateMixinのおかげらしい。ということはアニメーションのコントロールをするオブジェクトはthisじゃなくても良いのだろうけど、どういう時に使うのかな。あと、ここでアニメーションの実行時間(duration)を指定してる。個別のアニメーションじゃなくて、コントローラー配下のアニメーションは全部同じ再生時間になるんだね」

私「slideOutAnimationは右にスライドするアニメーションだけど、その挙動はbeginendで指定してる。2次元だから縦方向のスライドも指定可能だね。あと、Offsetの値はオブジェクトのサイズとの比だ。だからOffset(1.0, 0.0)だと1個分右ということになる。変にサイズを取得したりしなくて良いから助かる」

私「Tween()に対して.animate()を適用するとアニメーションができるんだけど、ここでこのアニメーションを制御するコントローラーと値の変化具合を指定している。なんか回りくどいけどそういうものらしい」

build

私「ここでいよいよbuild()に入るわけだけど、Flutterのアニメーションが分かりにくい原因がここにあるから先に説明しておく。Flatterではbuild()で表示するオブジェクトを構築するんだけど、アニメーションで動かすオブジェクトはその過程でそれように作らなければならない」

      body: ReorderableListView(
        onReorder: ...
        children: <Widget>[
          for (int i = 0; i < _items.length; ++i)
            SlideTransition(
              key: Key('$i'),
              position: _fromIndex == i ? slideOutAnimation : stopAnimation,
              child: ListTile(title: Text('Item ${_items[i]}')))
        ],
      ),

私「今回の場合、ReorderableListView()children:に行のリストを設定するのだけれど、それを単なるListTile()じゃなくてSlideTransition()で包んで、positoin:にアニメーションを設定する。この時、_fromIndexに一致する削除対象の行にはslideOutAnimationを、その他にはstopAnimationを設定する。この状態でanimationController.forward()を実行すると削除対象の行だけ右にスライドするというわけ」

私「ただし無闇にforward()してもアニメーションが見えなかったりする。今回の場合、まず削除対象の行にslideOutAnimationを設定した後にアニメーションし、それが完了した後に実際に行を削除しなければならない。タイミングを図る必要がある」

          IconButton(
            icon: const Icon(Icons.delete),
            onPressed: () {
              _fromIndex = 1;
              setState(() {
                animationController.reset();
              });
              WidgetsBinding.instance.addPostFrameCallback((_) {
                void listener(AnimationStatus status) {
                  if (status == AnimationStatus.completed) {
                    animationController.removeStatusListener(listener);
                    setState(() {
                      _items.removeAt(_fromIndex);
                    });
                    _fromIndex = -1;
                  }
                };
                animationController.addStatusListener(listener);
                animationController.forward();
              });
            },
          )

私「上記が削除ボタンが押されたときの処理。まず_fromIndexに値を設定してからsetState()を実行している。そうするとbuild()が実行されるのだけれど非同期だからすぐに実行されるわけじゃない。なのでその実行を待つのがWidgetsBinding.instance.addPostFrameCallback((_) {の部分。ここでbuild()の実行を待たないと、_fromIndexがまだ反映されていないからアニメーションしない」

私「無事に反映されたらforward()を実行するわけだけど、ここにも罠が。アニメーションが終わる前に_itemsから削除すると、build()が再実行され、アニメーションが消されてしまうのだ」

                void listener(AnimationStatus status) {
                  if (status == AnimationStatus.completed) {
                    animationController.removeStatusListener(listener);
                    setState(() {
                      _items.removeAt(_fromIndex);
                    });
                    _fromIndex = -1;
                  }
                };

私「というわけでAnimationStatusListenerを作ってアニメーションの終了後にアイテムを削除。同時に設定していたAnimationStatusListenerを外さないと何度もリッスンしてバグる。そして次の削除アニメーションが設定されないように_fromIndexに-1を代入する」

                animationController.addStatusListener(listener);
                animationController.forward();

私「ここまで準備してやっとaddStatusLister()forward()を実行すればスライドアウトして行が削除される」
娘「でも行が削除された後、その下の行がピョンって移動してるよ?ニュルってならないの?」
私「うっ。見た目にはもう良い気がするけど、やっぱりニュルってしたいよね。これでどうだ」

Animated Removable ReorderableListView
remove_anime_comp

娘「できてる!」
私「変更点を説明しよう」

  
  void initState() {
    ...
    upAnimation = Tween<Offset>(begin: Offset.zero, end: const Offset(0.0, -1.0))
      .animate(CurvedAnimation(
        parent: animationController,
        curve: Curves.easeInOut,
      ));
  }

私「まずupAnimationを作る。これは元いた場所から上に一つ移動するアニメーションだ」

  
  Widget build(BuildContext context) {
    var animations = List.generate(_items.length, (_) => stopAnimation);
    if (0 <= _fromIndex) {
      animations[_fromIndex] = slideOutAnimation;
      for (int i = _fromIndex + 1; i < _items.length; ++i) {
        animations[i] = upAnimation;
      }
    }
    ...

私「次にbuild()の冒頭でanimationsというstopAnimationのリストを作る。そして_fromIndexが設定されている時、animations[_fromIndex]にはslideOutAnimationを、それ以降の行にはupAnimationを設定する」

      body: ReorderableListView(
        ...
        children: <Widget>[
          for (int i = 0; i < _items.length; ++i)
            SlideTransition(
                key: Key('$i'),
                position: animations[i],
                child: ListTile(title: Text('Item ${_items[i]}')))
        ],
      ),

私「そしてSlideTransitionposition:にanimationsの要素を設定する。これでできた」
娘「最初からそうすればいいのに」
私「まあまあ。これで編集対象以外の行をごっそり移動させる方法が説明できた」

3. 挿入アニメーション

私「それでは挿入に参りましょう。先に方針を説明します。今回は先に_itemsに要素を追加してから、追加要素のスライドインとそれ以降の要素の下方向の移動を行う。下方向の移動は追加後に行うから、本来の位置より一つ上の位置から元の位置に戻るアニメーションになる」

Animated Addable ReorderableListView
add_anime_comp

ソースコード
  
  void initState() {
    super.initState();
    animationController = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 300));
    slideInAnimation = Tween<Offset>(begin: const Offset(1.0, 0.0), end: Offset.zero)
        .animate(CurvedAnimation(
          parent: animationController,
          curve: Curves.easeInOut,
    ));
    downAnimation = Tween<Offset>(begin: const Offset(0.0, -1.0), end: Offset.zero)
        .animate(CurvedAnimation(
          parent: animationController,
          curve: Curves.easeInOut,
    ));
  }

  
  Widget build(BuildContext context) {
    var animations = List.generate(_items.length, (_) => stopAnimation);
    if (0 <= _toIndex) {
      animations[_toIndex] = slideInAnimation;
      for (int i = _toIndex + 1; i < _items.length; ++i) {
        animations[i] = downAnimation;
      }
    }
    return Scaffold(
      appBar: AppBar(
        title: const Text('Reorderable ListView'),
        actions: [
          IconButton(
            icon: const Icon(Icons.add),
            onPressed: () {
              _toIndex = 1;
              setState(() {
                animationController.reset();
                _items.insert(_toIndex, "new Item");
              });
              WidgetsBinding.instance.addPostFrameCallback((_) {
                animationController.forward();
              });
            },
          ),
        ],
      ),
      body: ReorderableListView(
        onReorder: (oldIndex, newIndex) {
          setState(() {
            if (newIndex > oldIndex) {
              newIndex -= 1;
            }
            final String item = _items.removeAt(oldIndex);
            _items.insert(newIndex, item);
          });
        },
        children: <Widget>[
          for (int i = 0; i < _items.length; ++i)
            SlideTransition(
                key: Key('$i'),
                position: animations[i],
                child: ListTile(title: Text('Item ${_items[i]}')))
        ],
      ),
    );
  }  

私「あれ?こちらのほうがアニメーションの終了を待ったりしなくてもできた。こちらを先に説明するほうが良かったか」
娘「いまさらしょうがない。次、移動よろしく」

4. 移動アニメーション

私「移動は上方向と下方向がある。削除と追加を組み合わせる」

Animated Move ReorderableListView
move_anime

ソースコード
  
  void initState() {
    super.initState();
    animationController = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 300));
    slideInAnimation =
        Tween<Offset>(begin: const Offset(1.0, 0.0), end: Offset.zero)
            .animate(CurvedAnimation(
      parent: animationController,
      curve: Curves.easeInOut,
    ));
    slideOutAnimation =
        Tween<Offset>(begin: Offset.zero, end: const Offset(1.0, 0.0))
            .animate(CurvedAnimation(
      parent: animationController,
      curve: Curves.easeInOut,
    ));
    upAnimation =
        Tween<Offset>(begin: Offset.zero, end: const Offset(0.0, -1.0))
            .animate(CurvedAnimation(
      parent: animationController,
      curve: Curves.easeInOut,
    ));
    downAnimation =
        Tween<Offset>(begin: Offset.zero, end: const Offset(0.0, 1.0))
            .animate(CurvedAnimation(
      parent: animationController,
      curve: Curves.easeInOut,
    ));
    animations = List.generate(_items.length, (_) => stopAnimation);
  }

私「upAnimationは現在位置から上に、downAnimationは現在位置から下に移動するアニメーション。animationsは外出ししておく」

ソースコード
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Reorderable ListView'),
        actions: [
          IconButton(
            icon: const Icon(Icons.arrow_downward),
            onPressed: () {
              _fromIndex = 1;
              _toIndex = 4;
              animations[_fromIndex] = slideOutAnimation;
              for (int i = _fromIndex + 1; i <= _toIndex; ++i) {
                animations[i] = upAnimation;
              }
              setState(() {
                animationController.reset();
              });
              WidgetsBinding.instance.addPostFrameCallback((_) {
                void listener(AnimationStatus status) {
                  if (status == AnimationStatus.completed) {
                    animationController.removeStatusListener(listener);
                    animationController.reset();
                    setState(() {
                      _items.insert(_toIndex, _items.removeAt(_fromIndex));
                      animations =
                          List.generate(_items.length, (_) => stopAnimation);
                      animations[_toIndex] = slideInAnimation;
                    });
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      animationController.forward();
                      _fromIndex = -1;
                      _toIndex = -1;
                    });
                  }
                };
                animationController.addStatusListener(listener);
                animationController.forward();
              });
            },
          ),
          IconButton(
            icon: const Icon(Icons.arrow_upward),
            onPressed: () {
              _fromIndex = 4;
              _toIndex = 1;
              animations[_fromIndex] = slideOutAnimation;
              for (int i = _toIndex; i < _fromIndex; ++i) {
                animations[i] = downAnimation;
              }
              setState(() {
                animationController.reset();
              });
              WidgetsBinding.instance.addPostFrameCallback((_) {
                void listener(AnimationStatus status) {
                  if (status == AnimationStatus.completed) {
                    animationController.removeStatusListener(listener);
                    animationController.reset();
                    setState(() {
                      _items.insert(_toIndex, _items.removeAt(_fromIndex));
                      animations =
                          List.generate(_items.length, (_) => stopAnimation);
                      animations[_toIndex] = slideInAnimation;
                    });
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      animationController.forward();
                      _fromIndex = -1;
                      _toIndex = -1;
                    });
                  }
                };
                animationController.addStatusListener(listener);
                animationController.forward();
              });
            },
          ),
        ],
      ),
      ...

私「_fromIndex_toIndexの大小で animationsの構成が異なる。いずれもaddPostFrameCallback()を2回使って編集したanimationsbuild()で反映されるまで待ってからforward()するのがミソ」
娘「すごく長くなった」
私「場合分けが必要になったからしかたない」
娘「次で最後?」
私「最後は交換」

5. 交換アニメーション

私「交換は間の移動がないから簡単」

Animated Move ReorderableListView
swap_anime

ソースコード
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Reorderable ListView'),
        actions: [
          IconButton(
            icon: const Icon(Icons.swap_vert),
            onPressed: () {
              _fromIndex = 1;
              _toIndex = 4;
              animations[_fromIndex] = slideOutAnimation;
              animations[_toIndex] = slideOutAnimation;
              setState(() {
                animationController.reset();
              });
              WidgetsBinding.instance.addPostFrameCallback((_) {
                void listener(AnimationStatus status) {
                  if (status == AnimationStatus.completed) {
                    animationController.removeStatusListener(listener);
                    animationController.reset();
                    setState(() {
                      var tmp = _items[_fromIndex];
                      _items[_fromIndex] = _items[_toIndex];
                      _items[_toIndex] = tmp;
                      animations[_fromIndex] = slideInAnimation;
                      animations[_toIndex] = slideInAnimation;
                    });
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      animationController.forward();
                      _fromIndex = -1;
                      _toIndex = -1;
                    });
                  }
                };
                animationController.addStatusListener(listener);
                animationController.forward();
              });
            },
          ),
        ],
      ),
      ...

娘「だいぶ短くなった」
私「交換だとリストの変化が少ないからね。スライドアウトの設定を待って、アニメーションを実行して、終了を待って、要素を交換して、スライドインの設定を待って、アニメーションの実行をする」
娘「全部合わせたのは?」
私「それは課題として残しておく」

Discussion