🤖

Flutterで特定の要素のところにスクロールさせるときに気をつけること

2024/06/08に公開

はじめに

最近はFlutterばっかりでkotlinに触れていない加藤です。&AIでインターンしてます。
今日はFlutter純正のScrollableクラスensureVisibleの簡単な使い方紹介と実際使ってみて詰まったポイントについてまとめます。
めちゃくちゃニッチなテーマかと思いきや、特定の要素のところまでスクロールさせたいというタイミングは結構あるので、知っておくと割と使えます。
(こっから丁寧語じゃなくなります。)

ensureVisibleとは

ScrollableクラスのensureVisibleは、指定した要素が表示されるようにスクロールするメソッド。
指定したい要素にGlobalKeyを渡して、そのGlobalKeyを目印にしてスクロールさせることが可能。
ただスクロールするだけではなく、ちょっとずらしたり、ゆっくりスクロールさせたり、スクロールに緩急をつけることもできる。

使う時のイメージはこんな感じ。

globalKeyを渡す

~~~
SizedBox(
  key: _hogehogeKey,
  height: 100,
  ~~~
),
~~~

呼び出す

~~~
    final context = _hogehogeKey.currentContext;
    if (context != null) {
      Scrollable.ensureVisible(
        context,
        alignment: 0.5,
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeInOut,
        alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
      );
    }
~~~

ここでensureVisibleにいくつか引数を渡しているがそれぞれの意味は以下

context

スクロールさせたい要素に渡したglobalKeyを目印に、currentContextを取得して渡す。
どこにスクロールさせるかの肝

alignment

その要素が画面内のどこら辺に見えていて欲しいかを指定するもの。
該当の要素を画面の一番上に表示させたい場合は0.0、真ん中に表示させたい場合は0.5、一番下に表示させたい場合は1.0を指定する。

durationとcurve

アニメーションの設定をするもの。
durationはどのくらいの時間をかけてスクロールさせるかを指定し、curveはスピードの緩急を指定する。

alignmentPolicy

その要素が画面内のどこら辺に見えていて欲しいかを指定するもの。
デフォルトはexplicitで、指定したalignmentに従う。
keepVisibleAtEndを指定すると、画面の一番下に表示されるようにスクロールする。
keepVisibleAtStartを指定すると、画面の一番上に表示されるようにスクロールする。

注意点1 alignmentとalignmentPolicyの違い

指定する引数の部分で混乱すると思う。
alignmentとalignmentPolicyの違いが分かりにくい。

alignmentPolicyの内部の実装を見ると少しわかる

    switch (alignmentPolicy) {
      case ScrollPositionAlignmentPolicy.explicit:
        target = clampDouble(viewport.getOffsetToReveal(object, alignment, rect: targetRect).offset, minScrollExtent, maxScrollExtent);
      case ScrollPositionAlignmentPolicy.keepVisibleAtEnd:
        target = clampDouble(viewport.getOffsetToReveal(object, 1.0, rect: targetRect).offset, minScrollExtent, maxScrollExtent);
        if (target < pixels) {
          target = pixels;
        }
      case ScrollPositionAlignmentPolicy.keepVisibleAtStart:
        target = clampDouble(viewport.getOffsetToReveal(object, 0.0, rect: targetRect).offset, minScrollExtent, maxScrollExtent);
        if (target > pixels) {
          target = pixels;
        }
    }

alignmentPolicyにexplicitを指定した場合は、alignmentに指定した値をそのまま使う。
というかこれはデフォルト値なので、alignmentを使いたい時にはalignmentPolicyを指定しなくても良い。
keepVisibleAtEndの場合は、alignmentを1.0とした時と同じだが、pixelsよりも小さい場合はpixelsになる。つまり画面からはみ出ないようことを確約する。
keepVisibleAtStartの場合は、alignmentを0.0とした時と同じだが、pixelsよりも大きい場合はpixelsになる。つまり画面からはみ出ないようことを確約する。
ということなのだろう。
知らんけど。

自分の理解だと、alignmentとalignmentPolicyはどっちかを使えばよく、
細かく位置を指定したいならaligmentを使う。
画面の一番上か下に表示するだけでよければalignmentPolicyのkeepVisibleAtEndかkeepVisibleAtStartを使う。

注意点2 ListViewだと使えないことが多い

以下の二つの理由でListViewとEnsureVisibleは相性が悪い

  1. 同じGlobalKeyを複数の要素に割り当ててしまう可能性がある
  2. ListViewはデフォルトで画面外の要素を描画しない。

一つ目は正しくコードを書けば防げるが二つ目が致命的。
まだ描画されてない画面外にある要素のcurrentContextを取得したくても取得できず、どこにスクロールすれば良いかわからなくなってしまう。

これを防ぐのに一番簡単なのはSingleChildScrollViewを使って中にColumnをおくこと。
https://stackoverflow.com/questions/49153087/flutter-scrolling-to-a-widget-in-listview

どっちにしろEnsureVisibleを使いたいならListViewは避けたほうが良いと思う。

余談: どうしてもListViewを使いたい場合

該当の要素のところにスクロールさせたいという要件を満たしたいなら
Googleさんが作っているscrollable_positioned_listを使うという方法もある。

しかも、scrollable_positioned_listはListViewのように、画面外に出た要素を再利用するリサイクルビューの特性を持っている。
要素数が大きいとかパフォーマンスが気になるようなページだとそっちを使ったほうが良いだろう。
パッケージを追加することを避けたいとかパフォーマンスは気にならないページならensureVisibleで十分だと思う。

注意点3 描画が終わってから呼び出さないとずれる可能性がある

よく見る実装例だとinitStateあたりでensureVisibleを呼び出しているが、これだと描画が終わっていないので、正しくスクロールされない可能性がある。
非同期でデータ取得処理を行っているページなど、まだ描画が終わっていないのにスクロールが始まると、正しい位置にスクロールしない。
個人的にはこれが一番ハマりやすいポイントだと思う。(のに大体の記事には書いてなくて困った。)

ベストプラクティスかは不明だが、回避策としてはWidgetBinding.instance.addPostFrameCallbackで呼ぶのが基本の対策となってくる。
しかしそのままBuildメソッド内で呼ぶかどうかは考える必要がある。
buildメソッド内で呼んでも良い場合
buildメソッドが一度しか呼ばれない画面ならこれでいける。
静的なリストならこれでいける場面も多いだろう。
実装イメージとしてはこんな感じ

Widget build(BuildContext context) {
    WidgetsBinding.instance?.addPostFrameCallback((_) {
        final context = _hogehogeKey.currentContext;
        if (context != null) {
          Scrollable.ensureVisible(・・・・)
        }
    });
~~~~省略~~~~
}

initState内で呼んだ方が良い場合
リストの中身をproviderで管理しているような場合はこちらのパターンになることが多いだろう。
以下の二つの要件を満たすような実装にする必要があるだろう。

  1. スクロール先の要素の情報が取得できて、再度ビルドされた後で処理を呼ぶ
  2. ビルドされるたびに処理を呼ばない

両方満たすためには、
initState()内で該当要素を監視するproviderを監視
→中身が取得されたら、addPostFrameCallback内にスクロール処理を登録
→一度登録されたら何度も登録されないように監視を終了
→該当要素がビルドされ、スクロール処理も呼ばれる

実装イメージとしてはこんな感じかな?

override
void initState() {
  super.initState();
    ref.listenManual(hogehogeProvider(huga: huga),
      (previous, next) {
    // 初期取得直後でない、もしくは現在値がない場合は弾く
    if ((previous?.hasValue ?? false) || !next.hasValue) {
      return;
    }
    // 一度動作した後は停止する
    _subscription.close();
    // 描画後スクロールする
    WidgetsBinding.instance.addPostFrameCallback((_) {
      final context = _hogehogeKey.currentContext;
      if (context != null) {
        Scrollable.ensureVisible(・・・・)
      }    });
  });
~~~~省略~~~~

}

initState内で呼ぶパターンはちょっとコードが複雑になってしまった、、、
もっといい方法があったら教えて強い人。

余談2: 要素のindexが固定されている場合

デートピッカーのように中の要素のサイズが固定で、keyを渡すよりindex番号で指定したいこともあると思う。
そういう時はScrollControllerを継承したFixedExtentScrollControllerクラスを使うことができる。

終わりに

ScrollControllerのEnsureVisibleを中心にスクロール周りを解説してみました。

Discussion