【Flutter】無限おにぎりスクロール 時間差スライドアニメーション / NotificationListener 🍙
はじめに
無限スクロールとStaggered Animationを組み合わせたデモアプリを作ってみました。Flutterはこうしたデモが比較的簡単に作れて、モバイルと同じものをウェブで共有できるのがいいですね。
スクロールする度にランダムに選ばれたおにぎりが時間差でスライドインします。半分お遊び半分学びで特に実用性を目指したものではありません。。笑

👇 完成コード(実行可)
学ぶこと
- NotificationListenerを利用した無限スクロール(ScrollControllerを利用しない)
- Staggered Animation(AnimatedWidgetを自作する、Interval Curveを理解する)
- 状況に応じてSnackBarやダイアログを出す
- リストのアイテム数がスクロールするほど多くない時にマウスのスクロールを検知する
- ListView.builderなどのスクロールビューでアニメーションの完了状態を維持する(再描画回避)
NotificationListenerを利用した無限スクロール
ScrollController.position.maxScrollExtentなどを利用する方法もありますが、今回はNotificationListenerを使ってみました。
NotificationListenerは対応する別Widgetからの通知をツリーの上で受け取ることができるWidgetです。ListView、GridView、SingleChildScrollViewなどのScrollable系Widgetからの通知もNotificationListenerで受け取ることができます。
Widget build(BuildContext context) {
    return Scaffold(
      body: NotificationListener<ScrollEndNotification>(
        onNotification: (notification) {
          final metrics = notification.metrics;
          if (metrics.extentAfter == 0) _addItems();
	  // trueを返すことで通知がこれ以上遡らない
          return true;
        },
          child: ListView.separated(),
//(省略)
このようにコンストラクタのGenericsのところにNotification型を指定することで、該当の通知を受け取ってコールバック処理を行えます。
ここで使用しているScrollEndNotificationはScrollNotificationを継承したクラスです。
Object > Notification > LayoutChangedNotification > ScrollNotification > ScrollEndNotification
ScrollNotificationには5人子供がいるようです。
- OverscrollNotification(スクロールの指示があるのに範囲外なのでスクロールできない場合の通知)
- ScrollEndNotification(スクロールが終わった場合の通知)
- ScrollStartNotification(スクロールが始まった場合の通知)
- ScrollUpdateNotification(スクロール位置が変わった場合の通知)
- UserScrollNotification(スクロール方向が変わった場合の通知)
ScrollEndNotification.metricsにより様々な指標を受け取ることができます。今回はextentAfter(現在のビューポートより下の潜在的画面量)がゼロになったのを検知して、おにぎりを追加する処理にしています。
おにぎりは全12種類の具材からランダムで5つ選ぶ設定です。
梅、ひじき、のり、五目、わかめ、焼きおにぎり、たらこ、天むす、高菜、赤飯、
鮭、昆布です。(どうでもいい)
Staggered Animation
概要
Staggered Animationとは時間差で発動するアニメーションのことです。英語で時差出勤のことをstaggered hoursなどと言ったりしますが、イメージとしてはそのstaggeredですね。
通常ならvalue 0.0から1.0で進行するアニメーションを、アニメーション対象のインスタンスごとにこれらの値を短くしたりずらしたりします。その方法や塩梅は様々だと思いますが、ここではインスタンスにindexを振って、その番号を元に値を変更します。
イメージとしてはこの図のような感じです。

カスタムのAnimatedWidgetを作る
class StaggeredSlideTransition extends AnimatedWidget {
  final Widget child;
  final int index;
  final Animation<Offset> indexedOffsetAnimation;
  StaggeredSlideTransition({
    Key? key,
    required this.child,
    required this.index,
    required Animation<double> animation,
  })  : indexedOffsetAnimation = animation
            .drive(
              CurveTween(
                curve: Interval(
                  index * (1 / 10),
                  index * (1 / 10) + (5 / 10),
                  curve: kCurve,
                ),
              ),
            )
            .drive(
              Tween<Offset>(
                begin: const Offset(1.0, 0.0),
                end: const Offset(0.0, 0.0),
              ),
            ),
        super(key: key, listenable: animation);
  
  Widget build(BuildContext context) {
    return FractionalTranslation(
      translation: indexedOffsetAnimation.value,
      child: child,
    );
  }
}
独自のアニメーションを施したWidgetを作る場合はAnimatedWidgetを継承します。AnimatedWidgetはAnimation valueが変わる度に再構築されるWidgetで、Animation<double>などのListenableを渡してやる必要があります。
AnimatedWidgetを継承したクラスとしてはFadeTransitionやSlideTransitionなどの~Transition系があります(すべてexplicit animation)。
今回はSlideTransitionに時間差の要素を加えたものなので 「StaggeredSlideTransition」 と名付けました。
indexedOffsetAnimation = animation
            .drive(
              CurveTween(
                curve: Interval(
                  index * (1 / 10), // begin value
                  index * (1 / 10) + (5 / 10), // end value
                  curve: kCurve,
                ),
              ),
            )
この部分ではインスタンスメンバーであるAnimation<double>にCurveの要素を加えています。パラメーターに指定したIntervalはCurveクラスを継承しており、通常のCurveの種類に加えて、0.0~1.0の間で表示開始と終了の値を指定できるクラスです。
この開始と終了の指定をindexの値によって変更することで、時間差でアニメーションが発動する仕組みです。
                  index * (1 / 10), // begin value
                  index * (1 / 10) + (5 / 10), // end value
ということは、開始と終了の値は前述の図の通り、
- index == 0 のとき → 開始0/10、終了5/10
- index == 1 のとき → 開始1/10、終了6/10
- index == 5 のとき → 開始5/10、終了10/10
となるわけです。
開始も終了も0.0から1.0の範囲に収まらないとエラーになるので、indexの数とアニメーション間隔のバランスをうまく取る必要があります。
            .drive(
              Tween<Offset>(
                begin: const Offset(1.0, 0.0),
                end: const Offset(0.0, 0.0),
              ),
            ),
        super(key: key, listenable: animation);
driveメソッドでさらに設定をチェインしてOffsetの概念を加えます。これはanimation valueが
- 0.0のとき → Offset(1.0, 0.0)
- 1.0のとき → Offset(0.0, 0.0)
となるようにしてください、というFlutterへの指示です。
Offsetは主に画面のx座標、y座標を表すのに使われたりしますが、今回は「本来そのWidgetがあるべき位置からのずれ具合」を表すものとして使っています。
Offset(1.0, 0.0)はx+方向にWidgetが1つ分ずれている状態、Offset(0.0, 0.0)は本来あるべきニュートラルな位置ということです。(画面右側・外から内へのスライドイン)
これはbuild内で使用しているFractionalTranslationというWidgetのtranslation(移動)というパラメーターの指定に由来します。
  Widget build(BuildContext context) {
    return FractionalTranslation(
      // ここでずれ具合としてのOffsetが必要
      translation: indexedOffsetAnimation.value,
      child: child,
    );
  }
状況に応じてSnackBarやダイアログを出す
現在の購入おにぎり数と購入金額合計をSnackBar(画面下に出る通知)で表示し、金額が1万円を超えるとダイアログが出る設計にします。
SnackBar
  SnackBar _createSnackBar() {
    final totalNum = _items.length;
    _totalCost.value = _items.fold<int>(0, (acc, item) => acc + item.price);
    return SnackBar(
      content: Text(
        'You bought $totalNum ONIGIRIs\nTotal payment: ${_totalCost.value} yen',
        style: const TextStyle(fontSize: 30),
        textAlign: TextAlign.center,
      ),
      behavior: SnackBarBehavior.floating,
    );
  }
まずはSnackBarオブジェクトを作ります。今回は金額の計算などを入れる必要があるためSnackBarを返すメソッドにしています。
そして作成したSnackBarをScaffoldMessenger.showSnackBarで表示。
  void _addItems() {
    if (!_initiallyScrolled) _initiallyScrolled = true;
    setState(() {
      _items.addAll(_pickRandomItems());
    });
    ScaffoldMessenger.of(context)
      // 前回の表示が残っていれば上書き
      ..removeCurrentSnackBar()
      ..showSnackBar(_createSnackBar());
  }
この_addItems()はNotificationListenerでScrollEndを検知すると実行されます。(それ以外にも、初期画面でマウスのホイールをスクロールしたタイミングでも実行。詳細は後述)
ダイアログ
  // 値をlistenして1万円超えたら警告
  final _totalCost = ValueNotifier(0);
  void initState() {
    super.initState();
    _items.addAll(_pickRandomItems());
    _totalCost.addListener(_notifyBudgetOver);
  }
  void _notifyBudgetOver() {
    if (_totalCost.value >= kBudget && !_budgetOverNotified) {
      showDialog(
        context: context,
        builder: (context) {
          return const AlertDialog(
            title: Text('Your payment is over $kBudget yen.'),
          );
        },
      );
      _budgetOverNotified = true;
    }
  }
ListenableであるValueNotifierに、トータル金額が1万円以上になったらshowDialog()を実行してくれるlistenerを設定します。
リストのアイテム数がスクロールするほど多くない時にマウスのスクロールを検知する

アイテム数が少なくてスクロールを検知できない
ListViewで初期アイテム数が画面の外に出るほど多くない場合、デフォルトの設定ではスワイプやマウスのスクロールをうまく検知できません。特殊な状況だと思いますが、その対策です。
スクロールできない状態でスワイプアップ動作に対応する
     child: ListView.separated(
            // 初期アイテム数が少なくスクロールしない場合の対策
            physics: const AlwaysScrollableScrollPhysics(),
            itemBuilder: (context, index) { //(省略)
phsyicsパラメーターにAlwaysScrollableScrollPhysics()を指定すれば、スクロール不可な状態でもスワイプ動作が可能になり、NotificationListenerやScrollControllerで状態検知できるようになります。
スクロールできない状態でマウスのスクロールダウンに対応する
NotificationListener<ScrollEndNotification>(
	onNotification: //(省略)
        child: Listener(
          onPointerSignal: (event) {
            if (!_initiallyScrolled && event is PointerScrollEvent) {
              _addItems();
            }
          },
	  child: ListView.separated( //(省略)
この場合は少し強引ですが、NotificationListenerとListViewの間にさらにListenerを挟んで、onPointerSignalでマウスポインタイベントを検知します。
ただし初回スクロールが済んでいないことを条件に検知しないと、NotificationListenerと被ってしまいますのでご注意を。
NotificationListenerは他のWidgetからの通知を検知するWidgetですが、Listenerはポインタからのイベント全般を検知するWidgetです。
ListView.builderなどのスクロールビューでアニメーションの完了状態を維持する(再描画回避)
ListView.builderやListView.separatedは画面に見えてる範囲とその周辺だけを、スクロールに応じて描画しています。なのでいったんアイテムを表示させて、その後スクロールして戻ると、そのアイテムは再描画されます。(Lazy List)
メモリ管理の観点からこれは是ですが、アイテムがAnimationControllerを持っている場合、アニメーションの状態が再表示の度に元に戻り、再度アニメーションが発動してしまうことになります。
このような場合はリストのアイテムWidgetをAutomaticKeepAliveというWidgetでラッピングしてやるか、アイテムWidgetのStateクラスにAutomaticKeepAliveClientMixinを実装することで再描画を回避できます。
本デモでは後者の方法を採っています。
(ListView.addAutomaticKeepAlives がtrueであることが前提ですが、これはデフォルトでtrueになってます)
class _AnimatedItemTileState extends State<AnimatedItemTile>
    with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
//(省略)
  // これをoverrideする必要あり
  
  bool get wantKeepAlive => true;
  
  Widget build(BuildContext context) {
    // AutomaticKeepAliveClientMixinのときはsuperを呼ぶ
    super.build(context);
    return StaggeredSlideTransition( //(省略)
最後に
いらすとやさんのおにぎり素材を利用させていただきました。
こちらのスクロールに関する記述を参考にさせていただきました。
ありがとうございました。


Discussion