torikatsu.dev

Flutterとかプログラミングとかガジェットとか書きます

【Flutter】ListViewで子要素がListViewの内側に表示されたか検知する

こんにちは、とりかつ(https://twitter.com/torikatsu923)です。 いきなりですが、FlutterにはListViewという大変便利なWidgetがあります。

api.flutter.dev

ListView+ListTileを使うことで数分で以下のようなUIを作ることができます。

f:id:torikatsu923:20210730005502p:plain:h600

このような画面をさくっと実装できるのはFlutterのとてもいいところだと思います。

最近、ListViewの要素が画面内に表示されたかどうか知りたいケースがありました。 そこで今回の記事は、ListViewの要素がListViewの内側に表示されたことを検知する方法について紹介します。

今回紹介する方法を使うと、ListViewの内側に表示されている間だけ色を変えるみたいな実装も実現可能です。

f:id:torikatsu923:20210730233808g:plain:h600

コード

今回の記事で紹介するコードは以下のリポジトリです。 github.com

方針

ListViewの要素がListView内に表示されたかどうかは、ListViewの表示領域とListViewの要素の座標を比較することで判断することができます。 図にすると以下のようになります。 f:id:torikatsu923:20210730012837p:plain:h600 要素がListViewの内側にあるとき、要素の下端のy座標はListViewの下端のy座標よりも小さくなります。 逆に要素がListViewの外側にあるとき、要素の下端のy座標はListViewの下端のy座標よりも大きくなります。

これをまとめると、以下のようになります。

要素がListViewの内側にあるときの条件: `要素の下端のy座標 <= ListViewの下端のy座標`  
要素がListViewの外側にある時の条件  : `要素の下端のy座標 > ListViewの下端のy座標`

この判定をListViewのスクロールイベントが発生するごとに行います。
そうすることで、ListViewの要素がListViewの内側に完全に入ったことを検知することができます。

実際にこれを実装していきます。実装手順は以下の通りです。

  1. ListViewの要素の下端の座標を取得する
  2. ListViewの要素で、ListViewの表示領域の下端の座標を取得する
  3. 1、2で取得した座標を使って、要素がListViewの領域内に表示されているかどうかの判定を行う
  4. 3の判定をスクロールイベントが発生するたびに行う

1. ListViewの要素の下端の座標を取得する

まずはListViewの要素の下端の座標を取得します。下端の座標はRenderObjectから取得します。 FlutterはWidget、Element、RenderObjectの3つのツリーでUIを構築しているのですが、RenderObjectは実際に描画された時のサイズや座標を持っています。

https://flutter.dev/images/arch-overview/trees.png

任意のWidgetに対応するcontext経由で以下のように取得することができます。 とってくるRenderObjectの実体はRenderBoxなので、取得するついでにキャストしています。

visibility_detector_list_item.dart

    final renderBox = context.findRenderObject() as RenderBox?;

次にRenderBoxからグローバル座標を取得します。 グローバル座標の取得にはRenderBoxのlocalToGlobal()を使用します。

api.flutter.dev

今回は単にグローバル座標を取得したいだけなので、Offset.zeroを渡してあげます。 ここまでのコードは以下のようになります。

visibility_detector_list_item.dart

    final renderBox = context.findRenderObject() as RenderBox?;
    if (renderBox == null) return;
    final globalOffset = renderBox.localToGlobal(Offset.zero);

2. ListViewの要素で、ListViewの座標を取得する

1の手順と同様にcontext経由でRenderObject経由でListViewの座標を取得します。 問題はどうやってListViewの要素からListViewのcontextにアクセスするかです。 やり方はいろいろあると思いますが、今回はGlobalKeyを使います。 ListViewを含むWidget側でGlobalKeyを生成し、ListViewのコンストラクタに渡します。 さらに、ListViewの要素側でこのGlobalKeyを受け取れるようにしてあげます。

visibility_detectable_list_view.dart

  final listViewKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemBuilder: (_, index) =>
          ListItem(index: index, listViewKey: listViewKey),
      separatorBuilder: (_, __) => const Divider(height: 0),
      itemCount: 100,
      key: listViewKey, // pass global key
    );
  }

ListView側では、GlobalKeyのcurrentContext経由でcontextにアクセスし、1の手順と同様にListViewのグローバル座標を取得します。 ここまでのコードは以下のようになります。

visibility_detectable_list_item.dart

    final renderBox = context.findRenderObject() as RenderBox?;
    final listViewRenderBox =
        widget.listViewKey.currentContext?.findRenderObject() as RenderBox?;

    if (renderBox == null || listViewRenderBox == null) return;
    
    final globalOffset = renderBox.localToGlobal(Offset.zero);
    final listViewGlobalOffset = listViewRenderBox.localToGlobal(Offset.zero);

3. 1、2で取得した座標を使って、要素がListViewの領域内に表示されているかどうかの判定を行う

ListViewの要素がListView内にあるかどうかの条件は冒頭で紹介しました。 条件分岐にはウィジェットの最下端のグローバル座標が必要になるので、まずはその座標を計算します。

1、2でlocalToGlobal(Offset.zero)を使って取得したグローバル座標は、ウィジェットの左上の座標になります。 以下の図のようなイメージです。

f:id:torikatsu923:20210730224141p:plain:h600

ウィジェットの再下端の座標は、1、2で取得したグローバル座標にウィジェットの高さを足すことで算出できます。 ウィジェットの高さはRenderBox.size.heightで取得することができます。

それぞれのウィジェットの再下端を取得できたら、あとは冒頭で紹介した条件に突っ込むだけです。 ここまでのコードは以下のようになります。

    final renderBox = context.findRenderObject() as RenderBox?;
    final listViewRenderBox =
        widget.listViewKey.currentContext?.findRenderObject() as RenderBox?;

    if (renderBox == null || listViewRenderBox == null) return;

    final listItemBottomPosition =
        renderBox.localToGlobal(Offset.zero).dy + renderBox.size.height;

    final listViewBottomPosition =
        listViewRenderBox.localToGlobal(Offset.zero).dy +
            listViewRenderBox.size.height;

    if (listItemBottomPosition <= listViewBottomPosition) {
      // inside
    } else {
      // outside
    }

4. 3の判定をスクロールイベントが発生するたびに行う

3でつくったの判定のロジックをスクロールイベントが発生するたびに行うことで、ListViewの要素ががListView内に表示された瞬間に、そのことを検知することができます。 そのためにはScrollControllerを使用します。 まず、ListViewにScrollControllerを設定します。さらにListViewの要素でScrollControllerのリスナに3で作った処理を設定します。

visibility_detectable_list_item.dart

class _VisibilityDetectableListItemState
    extends State<VisibilityDetectableListItem> {
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    WidgetsBinding.instance?.addPostFrameCallback(
      (_) => widget.controller.addListener(_onScroll),
    );
  }

  @override
  void dispose() {
    super.dispose();
    widget.controller.removeListener(_onScroll);
  }

_onScroll()は3で作った処理をまとめた関数です。

ここで大事なのは、リスナを設定するタイミングです。 ListViewの要素がListView内に描画されているかどうかの条件判定はcontextにアクセスする必要があります。 画面がレンダリングされる前にcontextにアクセスできないので、didChangeDependenciesWidgetBinding.instance?.addPostFrameCallback()を使ってリスナを設定しています。

また、リスナを開放ことも非常に重要です。 ListViewは画面内に入りそうな要素を必要に応じて作成・破棄します。なので、画面がスクロールされると要素が破棄されることがあります。 要素が破棄された場合、onScrollは存在しなくなります。 リスナを解放しないとスクロールイベントが発生した時、存在しないonScroll()を呼び出そうとしてメモリリークが起きます。 そのため、disposeで確実にScrollController.removeListener()してあげます。

実装は以上になります!これで以下のように画面内に表示されたことを検知できるようになりました!

f:id:torikatsu923:20210730233808g:plain:h600

おわりに

今回はグローバル座標を基準に、ListViewの要素がListView内に表示されたかどうかを検知する方法を紹介しました。 localToGlobal()にListViewのRenderObjectを渡すことで、ListViewとListViewの要素の相対的な座標で判定を行うこともできそうです。

ListViewで子要素がListViewの内側に表示されたか検知する方法に番兵を用いる方法もありました。 qiita.com

番兵を用いる方法は非常にシンプルでとっつきやすいです。 ですが、管理するWidgetの数が増えてしまいます。また、番兵を用いるとbuildが呼ばれたタイミングで初めてListViewで子要素がListViewの内側に表示されたかを検知することができるのですが、Widgetが破棄されていない等の理由で微妙に扱いづらい部分があります。 その点今回の記事で紹介した方法は汎用性がかなり高いため、安定した動作を求めるならこの記事で紹介した実装方法がおすすめです!