🎃

ListViewに移動アニメーションを付ける

2024/08/24に公開

ListViewに移動アニメーションを付ける

娘:「前回だけど、移動がさ、逃げてない?」
私:「というと?」
娘:「右に一回捌けてから戻って来るアニメになってる。ユーザーが手で動かした時みたいに、行が縦に移動するようにできないの?その方が移動したことが分かりやすい」
私:「鋭いところを突かれたな。実はオブジェクトの重なりに課題があって、重ならないようにしてたんだ。でも解決方法を見つけたよ。今回はよりシンプルになるようListViewに移動アニメーションを付与する。ReorderableListViewにも同じように適用できるから、自分で試してね」

基本のListView

私「これはアニメーションなしのListView。アイコンをクリックすると2行目が5行目に移動したりその逆に移動したりする。アニメーションがないからすごく分かりにくい。これにアニメーションを付けて行くよ」
Basic ListView

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

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

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

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

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ListView'),
        actions: [
          IconButton(
            icon: const Icon(Icons.arrow_downward),
            onPressed: () {
              setState(() {
                _items.insert(4, _items.removeAt(1));
              });
            },
          ),
          IconButton(
            icon: const Icon(Icons.arrow_upward),
            onPressed: () {
              setState(() {
                _items.insert(1, _items.removeAt(4));
              });
            },
          ),
        ],
      ),
      body: ListView(
        children: <Widget>[
          for (int i = 0; i < _items.length; ++i)
            ListTile(title: Text('Item ${_items[i]}'))
        ],
      ),
    );
  }
}

最初のアニメーション

First ListView

ソースコード
class _AnimatedListExampleState extends State<AnimatedListExample>
    with SingleTickerProviderStateMixin {
  final List<String> _items = List.generate(20, (i) => '${i + 1}');
  late final AnimationController animationController;
  late List<Widget> widgetList;
  
  
  void initState() {
    super.initState();
    animationController = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 300));
    widgetList = [
      for (int i = 0; i < _items.length; ++i)
        ListTile(key: GlobalKey(), title: Text('Item ${_items[i]}'))
    ];
  }

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ListView'),
        actions: [
          IconButton(
            icon: const Icon(Icons.arrow_downward),
            onPressed: () {
              int fromIndex = 1;
              int toIndex = 4;
              _items.insert(toIndex, _items.removeAt(fromIndex));
              widgetList = [
                for (int i = 0; i < _items.length; ++i)
                  ListTile(key: GlobalKey(), title: Text('Item ${_items[i]}'))
              ];
              var upAnimation = Tween<Offset>(
                      begin: const Offset(0.0, 1.0), end: Offset.zero)
                  .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));

              for (int i = fromIndex; i < toIndex; ++i) {
                widgetList[i] = SlideTransition(
                  key: GlobalKey(),
                  position: upAnimation,
                  child: widgetList[i],
                );
              }
              var slideInAnimation =
                  Tween<Offset>(begin: const Offset(1.0, 0.0), end: Offset.zero)
                      .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));
              widgetList[toIndex] = SlideTransition(
                key: GlobalKey(),
                position: slideInAnimation,
                child: widgetList[toIndex],
              );
              setState(() {
                animationController.reset();
                animationController.forward();
              });
            },
          ),
          IconButton(
            icon: const Icon(Icons.arrow_upward),
            onPressed: () {
              int fromIndex = 4;
              int toIndex = 1;
              _items.insert(toIndex, _items.removeAt(fromIndex));
              widgetList = [
                for (int i = 0; i < _items.length; ++i)
                  ListTile(key: GlobalKey(), title: Text('Item ${_items[i]}'))
              ];
              var downAnimation = Tween<Offset>(
                      begin: const Offset(0.0, -1.0), end: Offset.zero)
                  .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));

              for (int i = toIndex + 1; i <= fromIndex; ++i) {
                widgetList[i] = SlideTransition(
                  key: GlobalKey(),
                  position: downAnimation,
                  child: widgetList[i],
                );
              }
              var slideInAnimation =
                  Tween<Offset>(begin: const Offset(1.0, 0.0), end: Offset.zero)
                      .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));
              widgetList[toIndex] = SlideTransition(
                key: GlobalKey(),
                position: slideInAnimation,
                child: widgetList[toIndex],
              );
              setState(() {
                animationController.reset();
                animationController.forward();
              });
            },
          ),
        ],
      ),
      body: ListView(
        children: widgetList,
      ),
    );
  }
}

私「これは前回とほぼ同じで、挟まれる行を上下に移して、移動する行が右からスライドしてくる。
すこし復習すると、_AnimatedListExampleStateにwith SingleTickerProviderStateMixinを付けてアニメーションのイベントを受け取れるようにしている。これによって、AnimationControllerを生成するところでvsync:にthisを指定できるようになる。あとはbuildの中でそれぞれのウィジットにアニメーションを付けたり付けなかったりしている」
娘「上下スライドはよ」

上下スライド移動

Up Down Slide

ソースコード
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ListView'),
        actions: [
          IconButton(
            icon: const Icon(Icons.arrow_downward),
            onPressed: () {
              int fromIndex = 1;
              int toIndex = 4;
              _items.insert(toIndex, _items.removeAt(fromIndex));
              widgetList = [
                for (int i = 0; i < _items.length; ++i)
                  ListTile(key: GlobalKey(), title: Text('Item ${_items[i]}'))
              ];
              var upAnimation =
                  Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
                      .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));

              for (int i = fromIndex; i < toIndex; ++i) {
                widgetList[i] = SlideTransition(
                  key: GlobalKey(),
                  position: upAnimation,
                  child: widgetList[i],
                );
              }
              var slideInAnimation =
                  Tween<Offset>(begin: Offset(0, -(toIndex - fromIndex).toDouble()), end: Offset.zero)
                      .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));
              widgetList[toIndex] = SlideTransition(
                key: GlobalKey(),
                position: slideInAnimation,
                child: widgetList[toIndex],
              );
              setState(() {
                animationController.reset();
                animationController.forward();
              });
            },
          ),
          IconButton(
            icon: const Icon(Icons.arrow_upward),
            onPressed: () {
              int fromIndex = 4;
              int toIndex = 1;
              _items.insert(toIndex, _items.removeAt(fromIndex));
              widgetList = [
                for (int i = 0; i < _items.length; ++i)
                  ListTile(key: GlobalKey(), title: Text('Item ${_items[i]}'))
              ];
              var downAnimation = Tween<Offset>(
                      begin: const Offset(0.0, -1.0), end: Offset.zero)
                  .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));

              for (int i = toIndex + 1; i <= fromIndex; ++i) {
                widgetList[i] = SlideTransition(
                  key: GlobalKey(),
                  position: downAnimation,
                  child: widgetList[i],
                );
              }
              var slideInAnimation =
                  Tween<Offset>(begin: Offset(0, (fromIndex - toIndex).toDouble()), end: Offset.zero)
                      .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));
              widgetList[toIndex] = SlideTransition(
                key: GlobalKey(),
                position: slideInAnimation,
                child: widgetList[toIndex],
              );
              setState(() {
                animationController.reset();
                animationController.forward();
              });
            },
          ),
        ],
      ),
      body: ListView(
        children: widgetList,
      ),
    );
  }
}

娘「流石にちょっと長いね。説明よろ」
私「build()の中でどこにどんなアニメーションを配置するかが肝なんだけど、移動が上から下なのか下から上なのかで処理を変える必要がある」
娘「上から下だと、間に挟まれた行は上に移動させなきゃだけど、下から上だと逆になるからだね」
私「そうそう。ほぼ同じだから上から下の場合だけ説明すると、Icons.arrow_downwardってアイコンをタップした時の処理がその下のonPressed:のところに書いてある。その中で間の行に一つ上に移動するアニメーションを設定しているのが以下の部分」

              var upAnimation =
                  Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
                      .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));
              for (int i = fromIndex; i < toIndex; ++i) {
                widgetList[i] = SlideTransition(
                  key: GlobalKey(),
                  position: upAnimation,
                  child: widgetList[i],
                );
              }

私「SlideTransition()で囲むことでウィジットがアニメーションされるようになる。アニメーションは本来の位置より一つ下から本来の位置に移動するような設定だね。これは先に以下の部分で配列の内容を移動させてるから、アニメーションをそのように設定するんだ」

_items.insert(toIndex, _items.removeAt(fromIndex));

私「そして以下の部分で、移動対象の行を指定量移動させるアニメーションを設定」

              var slideInAnimation =
                  Tween<Offset>(begin: Offset(0, -(toIndex - fromIndex).toDouble()), end: Offset.zero)
                      .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));
              widgetList[toIndex] = SlideTransition(
                key: GlobalKey(),
                position: slideInAnimation,
                child: widgetList[toIndex],
              );

私「最後に以下でアニメーションを実行」

              setState(() {
                animationController.reset();
                animationController.forward();
              });

娘「なんだ。簡単にできんじゃん」
私「そうかな?じゃあユーザが移動させる場合と同じように影をつけてみよう」

影付きアニメーション

Shadow Slide

              widgetList[toIndex] = SlideTransition(
                key: GlobalKey(),
                position: slideInAnimation,
                child: Material(
                  elevation: 4,
                  child: widgetList[toIndex]),
              );

娘「影が消えないよ」
私「後始末するの忘れてた」
Shadow Slide

              setState(() {
                void listener(AnimationStatus status) {
                  if (status == AnimationStatus.completed) {
                    animationController.removeStatusListener(listener);
                    setState(() {
                      resetWidgetList();
                    });
                  }
                };
                animationController.reset();
                animationController.addStatusListener(listener);
                animationController.forward();
              });

私「アニメーションが終わった時に何か処理するにはこのように書く。ここでウィジットをリセット」
娘「影は消えるようになったけど、なんか変だよ。下がる時はいいけど、上がる時、字が重なってるような???」
私「よく気がついたね。実は上がる方向の時、移動している行が他の行の下に潜ってるんだ。今回、背景が透明だからまだいいけど、不透明だったりすると格好悪い」
娘「で、なんか解決したんでしょ?」
私「ちょっとトリッキーだけど」

潜らない移動

Upside Slide

ソースコード
          IconButton(
            icon: const Icon(Icons.arrow_upward),
            onPressed: () {
              int fromIndex = 4;
              int toIndex = 1;
              resetWidgetList();
              var downAnimation =
                  Tween<Offset>(begin: Offset.zero, end: const Offset(0.0, 1.0))
                      .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));

              for (int i = toIndex; i < fromIndex; ++i) {
                widgetList[i] = SlideTransition(
                  key: GlobalKey(),
                  position: downAnimation,
                  child: widgetList[i],
                );
              }
              var slideInAnimation = Tween<Offset>(
                      begin: Offset.zero,
                      end: Offset(0, -(fromIndex - toIndex).toDouble()))
                  .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));
              widgetList[fromIndex] = SlideTransition(
                key: GlobalKey(),
                position: slideInAnimation,
                child: Material(elevation: 4, child: widgetList[fromIndex]),
              );
              setState(() {
                void listener(AnimationStatus status) {
                  if (status == AnimationStatus.completed) {
                    animationController.removeStatusListener(listener);
                    setState(() {
                      _items.insert(toIndex, _items.removeAt(fromIndex));
                      resetWidgetList();
                    });
                  }
                };
                animationController.reset();
                animationController.addStatusListener(listener);
                animationController.forward();
              });
            },
          ),

娘「できてる!どうやったの?」
私「Flutterってbuild()の時、後から追加されたものが上に来る仕様なんだ。つまり下の行が上面になるわけ。これまでは、下から上に移動する時は、一旦上に移動させた行をアニメーションの時に下に移してから元に戻してたんで、移動してるウィジットは先に追加された、すなわち下面にいるので、アニメーションの時に下に潜っちゃったの。なので、下から上への移動の場合は順番を変更して、移動のアニメーションをしてから実際の移動をするようにした。すでにアニメーション終了時の処理が実装されてたから、そこに追加した」
娘「よく気がついたね」
私「ChatGPTも知らなかったんだよ」
娘「この記事読んだら、できるようになっちゃうね」

スワップアニメーション

私「最後にスワップも実装しよう」

Swap

ソースコード
          IconButton(
            icon: const Icon(Icons.swap_vert),
            onPressed: () {
              int index1 = 1;
              int index2 = 4;
              _items.insert(index2 - 1, _items.removeAt(index1));
              resetWidgetList();
              var downAnimation = const AlwaysStoppedAnimation(Offset(0, 1));
              for (int i = index1; i < index2 - 1; ++i) {
                widgetList[i] = SlideTransition(
                  key: GlobalKey(),
                  position: downAnimation,
                  child: widgetList[i],
                );
              }
              var slideDownAnimation = Tween<Offset>(
                begin: Offset(0, -(index2 - index1 - 1).toDouble()),
                end: const Offset(0, 1))
                  .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));
              widgetList[index2 - 1] = SlideTransition(
                key: GlobalKey(),
                position: slideDownAnimation,
                child: Material(elevation: 4, child: widgetList[index2 - 1]),
              );
              var slideUpAnimation = Tween<Offset>(
                begin: Offset.zero,
                end: Offset(0, -(index2 - index1).toDouble()))
                  .animate(CurvedAnimation(
                parent: animationController,
                curve: Curves.easeInOut,
              ));
              widgetList[index2] = SlideTransition(
                key: GlobalKey(),
                position: slideUpAnimation,
                child: Material(elevation: 4, child: widgetList[index2]),
              );
              setState(() {
                void listener(AnimationStatus status) {
                  if (status == AnimationStatus.completed) {
                    animationController.removeStatusListener(listener);
                    setState(() {
                      _items.insert(index1, _items.removeAt(index2));
                      _items.insert(index2, _items.removeAt(index2 - 1));
                      resetWidgetList();
                    });
                  }
                };
                animationController.reset();
                animationController.addStatusListener(listener);
                animationController.forward();
              });
            },
          ),

娘「なんか分かりにくくない?」
私「すごくトリッキーになっちゃった。動かすのが2つになるから、それらが共に上面に来るように下の2つの行を移動する行として使う。そして間の行はスワップの時は移動しないのに、移動してないように見せかけるためにあらかじめ一つずらしてるんだ。その時、AlwaysStoppedAnimation(Offset(0, 1))ってのを使っているんだけど、これ発見だった。アニメーションしないんだけど、アニメーション開始時に移動させておくという使い方。結構知られてないんじゃないかな」
娘「ふーん」

娘「なんかさ、ReorderableListViewをアニメーション化するFlutterのパッケージ作ってよ」
私「もうあるけど?」
娘「あれ使いづらいんだよ。Move,Swapないし。ちょっと気の利いたウィジットを行に置いたらよく分からんエラー吐くし」
私「分かった」
娘「ReorderableListViewを継承したやつ、よろ。AnimatedReorderableListViewね」
私「OK」

Discussion