🦁

【Flutter Widget of the Week #32】AnimatedListを使ってみた

2022/12/12に公開

はじめに

Flutter Widget of the Week #32 AnimatedList についてまとめましたので、紹介します。
https://youtu.be/ZtfItHwFlZ8

AnimatedList

動的リストでアイテムの挿入や削除、変更を行う場合、アニメーションなしでは不快感や困惑を生む恐れがあります。一斉にアイテムの位置が入れ替わったときの状況を把握するためにもアニメーション化させることは大切です。そんな時に効果的な widget が AnimatedList です。
では、さっそくサンプルを動かして使い方を見てみましょう。

AnimatedList サンプルコード

AnimatedList サンプル実行画面
AnimatedList サンプル実行画面

基本のサンプルコード全体

class AnimatedListSample extends StatefulWidget {
  const AnimatedListSample({super.key});

  
  State<AnimatedListSample> createState() => _AnimatedListSampleState();
}

class _AnimatedListSampleState extends State<AnimatedListSample> {
  final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
  late ListModel<int> _list;
  int? _selectedItem;
  late int
      _nextItem; // ユーザーが「+」ボタンを押したときに挿入される次の項目

  
  void initState() {
    super.initState();
    _list = ListModel<int>(
      listKey: _listKey,
      initialItems: <int>[0, 1, 2],
      removedItemBuilder: _buildRemovedItem,
    );
    _nextItem = 3;
  }

  // 削除されていないリスト項目を構築するために使用される
  Widget _buildItem(
      BuildContext context, int index, Animation<double> animation) {
    return CardItem(
      animation: animation,
      item: _list[index],
      selected: _selectedItem == _list[index],
      onTap: () {
        setState(() {
          _selectedItem = _selectedItem == _list[index] ? null : _list[index];
        });
      },
    );
  }

  // リストから削除された後、アイテムを構築するために使用される
  // このメソッドが必要な理由は、削除されたアイテムはそのアニメーションが完了するまで表示されたままのため
  Widget _buildRemovedItem(
      int item, BuildContext context, Animation<double> animation) {
    return CardItem(
      animation: animation,
      item: item,
    );
  }

// リストに「次の項目」を挿入する
  void _insert() {
    final int index =
        _selectedItem == null ? _list.length : _list.indexOf(_selectedItem!);
    _list.insert(index, _nextItem++);
  }

  // 選択した項目をリストから削除する
  void _remove() {
    if (_selectedItem != null) {
      _list.removeAt(_list.indexOf(_selectedItem!));
      setState(() {
        _selectedItem = null;
      });
    }
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('AnimatedList'),
          actions: <Widget>[
            IconButton(
              icon: const Icon(Icons.add_circle),
              onPressed: _insert,
              tooltip: 'insert a new item',
            ),
            IconButton(
              icon: const Icon(Icons.remove_circle),
              onPressed: _remove,
              tooltip: 'remove the selected item',
            ),
          ],
        ),
        body: Padding(
          padding: const EdgeInsets.all(16.0),
          child: AnimatedList(
            key: _listKey,
            initialItemCount: _list.length,
            itemBuilder: _buildItem,
          ),
        ),
      ),
    );
  }
}

typedef RemovedItemBuilder<T> = Widget Function(
    T item, BuildContext context, Animation<double> animation);

class ListModel<E> {
  ListModel({
    required this.listKey,
    required this.removedItemBuilder,
    Iterable<E>? initialItems,
  }) : _items = List<E>.from(initialItems ?? <E>[]);

  final GlobalKey<AnimatedListState> listKey;
  final RemovedItemBuilder<E> removedItemBuilder;
  final List<E> _items;

  AnimatedListState? get _animatedList => listKey.currentState;

  void insert(int index, E item) {
    _items.insert(index, item);
    _animatedList!.insertItem(index);
  }

  E removeAt(int index) {
    final E removedItem = _items.removeAt(index);
    if (removedItem != null) {
      _animatedList!.removeItem(
        index,
        (BuildContext context, Animation<double> animation) {
          return removedItemBuilder(removedItem, context, animation);
        },
      );
    }
    return removedItem;
  }

  int get length => _items.length;

  E operator [](int index) => _items[index];

  int indexOf(E item) => _items.indexOf(item);
}


/// その整数アイテムを 'item N' として、アイテムの値に応じた色のカードに表示する
/// [selected] が true の場合、テキストは明るい緑色で表示される
// このウィジェットの高さは、[animation] パラメータに基づき、0から128の範囲で変化する
// アニメーションが0.0から1.0まで変化するのに合わせて、0から128まで変化する
class CardItem extends StatelessWidget {
  const CardItem({
    super.key,
    this.onTap,
    this.selected = false,
    required this.animation,
    required this.item,
  }) : assert(item >= 0);

  final Animation<double> animation;
  final VoidCallback? onTap;
  final int item;
  final bool selected;

  
  Widget build(BuildContext context) {
    TextStyle textStyle = Theme.of(context).textTheme.headline4!;
    if (selected) {
      textStyle = textStyle.copyWith(color: Colors.lightGreenAccent[400]);
    }
    return Padding(
      padding: const EdgeInsets.all(2.0),
      child: SizeTransition(
        sizeFactor: animation,
        child: GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: onTap,
          child: SizedBox(
            height: 80.0,
            child: Card(
              color: Colors.primaries[item % Colors.primaries.length],
              child: Center(
                child: Text('Item $item', style: textStyle),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

使い方としては、
まずAnimatedList に itemBuilder を与えます。
これはリスト内の各インデックスに返される関数で、各アイテムを作成してくれます。
また、アイテムの追加時に自動的にアイテムを追加表示する animation も設定することができます。

AnimatedList(
  itemBuilder: (BuildContext context, int index, Animation<double> animation) {
    return CardItem(
      animation: animation,
      item: _list[index],
      selected: _selectedItem == _list[index],
      onTap: () {
        setState(() {
          _selectedItem = _selectedItem == _list[index] ? null : _list[index];
        });
      },
    );,
  },),
),

リストが空で始まらない限り、initialItemCount も与えます。
サンプルでは上記の itemBuilder の中身を _buildItem でまとめています・

AnimatedList(
  key: _listKey,
  initialItemCount: _list.length,
  itemBuilder: _buildItem,
),

さらに基となるデータ構造から何かが追加されたり、削除されたりするたびにAnimatedListに伝える必要が伝える必要があります。
それは、AnimatedListState のメソッドを呼び出すことでできます。
AnimatedListState には insertItem と removeItem の2つのメソッドがあります。
次に、AnimatedListState を取得するには、2つの方法があります。
一つ目の方法として、リストやアイテムの1つのコード内からアニメーションを開始する場合は、AnimatedList.of(context) を使います。

AnimatedList.of(context).insertItem(index);
AnimatedList.of(context).removeItem(
  index,
  (context, animation) => ...
);

一方、その他の場所から開始する場合は GlobalKey を使います。
AnimatedList にそれを追加することで、insertItem や removeItem をどこからでも呼び出せるようになります。

final _myListKey = GlobalKey<AnimatedListState>();

// ...

AnimatedList(
  key: _myListKey,
  initialItemCount: _myItems.length,
  itemBuilder: (context, index, animation){
    return MyListItem(_myItems[index]);
  },
);

insertItem や removeItem は AnimatedListState を呼び出すだけでなく、基となるデータも更新する必要があります。
サンプルでは GlobalKey を使う方法で、insertItem の場合は以下のように実装しています。

サンプルでのinserItem
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();

final List<E> _items;
AnimatedListState? get _animatedList => listKey.currentState;

void insert(int index, E item) {
  _items.insert(index, item);
  _animatedList!.insertItem(index);
}


AnimatedList(
  key: _listKey,
  initialItemCount: _list.length,
  itemBuilder: _buildItem,
),

removeItem も同様の形で実装できます。
以上により、基本的な AnimatedList の機能を実現できます。

AnimatedList のプロパティについて

AnimatedList にはいくつかプロパティがあるので、一部紹介します。

(new) AnimatedList AnimatedList({
  Key? key,
  required Widget Function(BuildContext, int, Animation<double>) itemBuilder,
  int initialItemCount = 0,
  Axis scrollDirection = Axis.vertical,
  bool reverse = false,
  ScrollController? controller,
  bool? primary,
  ScrollPhysics? physics,
  bool shrinkWrap = false,
  EdgeInsetsGeometry? padding,
  Clip clipBehavior = Clip.hardEdge,
})

①initialItemCount

リストが開始するアイテムの数を指定する
デフォルトは 0
型は int 型

②scrollDirection

スクロールビューがスクロールする軸を指定する
デフォルトは Axis.vertical
型は Axis 型

③reverse

スクロールビューを読み取り方向にスクロールさせるかどうかを指定する
デフォルトは false
型は bool 型

④controller

このスクロールビューがスクロールされる位置を制御するために使用されるオブジェクトを指定する
型は ScrollController 型

⑤physics

スクロールビューがユーザー入力にどのように反応するかを指定する
型は ScrollPhysics 型

⑥shrinkWrap

scrollDirection におけるスクロールビューの範囲を、表示中のコンテンツによって決定するかどうかを指定する
デフォルトは false
型は bool 型

最後に

今回は AnimatedList を紹介しました。最初にお伝えしたようにアニメーションがあるとないとでユーザビリティが変わってくると思いますので、ぜひ使ってみてください。
次は #33 Flexible です。またお会いしましょう。

参考記事

https://api.flutter.dev/flutter/widgets/AnimatedList-class.html

Discussion