[Flutter]ListViewのスクロール上げ下げで中の各Cardを既読に処理する方法

本記事は[Fusic Advent Calendar 2020](https://qiita.com/advent-calendar/2020/fusic)の20日目の記事です。

こんにちは。
この半年は勉強して来たFlutterを思いっきり実務で利用できる時間でした。
先日、無事にアプリをリリースできたので、色んな課題を乗り越えながら積み重ねて来たノートをそろそろ整理してアウトプットしようと思います。
どうぞよろしくお願いします。

# 概要
Flutterには様々な役割を持っているWidget classがあり、アプリの9割以上がWidgetで構成されています。
まるでLEGOのようにWidgetをはめ込む方式ですね。
今回はその中で画面に表示されているWidgetの位置を取得して処理する話です。
例えば以下の仕様です。

```
 1. データ(Card)が並んでる一覧(ListView)がある。
 2. 一覧(ListView)にはスクロールがある。
 3. 各データ(Card)は画面に入ったら読んだことになる。
```

# 設計
書かなくても良いほど簡単なところですが、LayoutとWidget Treeは以下のとおりです。
![스크린샷 2020-12-20 20.02.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108364/1baa37ff-a60b-e3c5-28c0-b0eb5c7e9f53.png)

# 実装
このような実装はとても単純なケースですが、3番の仕様は少し気にする必要があります。
> 3. 各データカードは画面に入ったら読んだことになる。

スクロールがある[ListView Widget](https://api.flutter.dev/flutter/widgets/ListView-class.html)に、[Card Widget]()が画面に全部入ったか という判断基準を決めないと行けないです。
例えばデータカードの立幅が長い場合、実は全部読んでなかったのに読んだことになるからですね。
こちらでは以下の設計をすることで対応できます。
![스크린샷 2020-12-20 20.09.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108364/66344e16-e312-f9a7-92a8-796555747334.png)

まず、ListViewのすぐ上端、下端に固定される別途の `位置取得Widget(A)`を配置させます。
また、スクロールエリアの中の各DataCardの上端、下端にも別途の `位置取得Widget(B)`を配置させます。
※ 位置取得Widget: 画面の中でY位置を取得するためのWidget

そして、スクロールのアップダウン操作をする時、`位置取得Widget(A)`と比較してDataCardの上端、下端になる`位置取得Widget(B)`がそれぞれ画面に表示されたかどうかを判断し記憶させます。

FlutterではBuildによって生成されたWidgetオブジェクトに[GlobalKey](https://api.flutter.dev/flutter/widgets/GlobalKey-class.html)を付けるとサイズや位置を取得できます。
また、[Listener](https://api.flutter.dev/flutter/widgets/Listener-class.html)を利用して画面のポイントタッチの操作に対してイベントを受信することができます。
これらを利用して上の設計通りに実装が可能になります。
その他の説明はコードに載せて置きます。

### HomeScreen Widget
```dart
// ./lib/home_screen.dart

import 'package:flutter/material.dart';
import 'dart:async';
import 'package:hoge/widgets/index_widget.dart';
import 'package:hoge/widgets/data_card_widget.dart';
import 'package:hoge/model/beer.dart';

// ホームスクリーンWidgetClass
class HomeScreen extends StatefulWidget {

  @override
  _HomeScreenState createState() => _HomeScreenState();
}

// ホームスクリーンステータス
class _HomeScreenState extends State<HomeScreen> {
  List<DataCard> _dataCardList;
  bool _indexDisplayFlg = false;
  // ... 省略

  @override
  void initState() {
    super.initState();
    this._loadData();
  }

  // 一覧データをロード
  void _loadData() async {
    BeerModel Beer = BeerModel();
    await Beer.getList().then((res) => {this._data = res});

    setState(() => this._indexDisplayFlg = true);
  }

  @override
  Widget build(BuildContext context) {

    return Scaffold(
      // ... 省略
      // 一覧Widgetを配置
      body: this._indexDisplayFlg ?
        IndexWidget(dataCardList: this._dataCardList) :
        CircularProgressIndicator()
  }
}

```

### Index Widget
```dart
// ./lib/widgets/index_widget.dart

import 'package:flutter/material.dart';
import 'package:hoge/widgets/data_card_widget.dart';

// 一覧WidgetClass
class Index extends StatefulWidget {
  Index({@required this.dataCardList});
  final List<DataCard> dataCardList;

  @override
  _IndexState createState() => _IndexState();
}

// 一覧Widgetステータス
class _IndexState extends State<Index> {
  List<DataCard> _dataCardList;
  final _scrollController = ScrollController();
  // 画面上、下端固定 位置取得Widget(A)の キー
  GlobalKey _fixedTopPointerKey = new GlobalKey();
  GlobalKey _fixedBottomPointerKey = new GlobalKey();
  // 画面上、下端固定 位置取得Widget(A)の Y値
  double _fixedTopY;
  double _fixedBottomY;

  @override
  void initState() {
    super.initState();
    this._dataCardList = widget.dataCardList;
  }

  // ビルド後の処理定義
  void _onAfterBuild() {
    // 画面上、下端固定 位置取得Widget(A)の Y値を取得してセット
    RenderBox fTPK = this._fixedTopPointerKey.currentContext.findRenderObject();
    RenderBox fBPK = this._fixedBottomPointerKey.currentContext.findRenderObject();
    double _fixedTopY = fTPK.localToGlobal(Offset.zero).dy;
    double _fixedBottomY = fBPK.localToGlobal(Offset.zero).dy;
  }

  // ポイントタッチの操作に対してイベントを受信処理
  void _setReadedFlg() async {
    this_dataCardList.forEach((DataCard dataCard) {
      // DataCardはすでに読んだ場合
      if (dataCard.isDisplayed()) {
        return;
      }

      // DataCardの上端が 画面に入ってたかを判断し、DataCardの上端表示済みフラグを切り替える
      if (dataCard.topPointerKey.currentContext != null) {
        RenderBox tPK = dataCard.topPointerKey.currentContext.findRenderObject();
        double topY = tPK.localToGlobal(Offset.zero).dy;
        if (topY > this._fixedTopY && topY < this._fixedBottomY) {
          dataCard.setTopDisplayed();
        }
      }

      // DataCardの下端が 画面に入ってたかを判断し、DataCardの下端表示済みフラグを切り替える
      if (dataCard.bottomPointerKey.currentContext != null) {
        RenderBox bpk =
            dataCard.bottomPointerKey.currentContext.findRenderObject();
        double bottomY = bpk.localToGlobal(Offset.zero).dy;
        if (bottomY > this._fixedTopY && bottomY < this._fixedBottomY) {
          dataCard.setBottomDisplayed();
        }
      }

      // 上端と下端が両方とも画面に表示された場合は、そのDataCardを既読の姿に切り替える
      if (dataCard.isDisplayed()) {
        // TODO 読んだwidgetの姿を変更
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    WidgetsBinding.instance.addPostFrameCallback((_) => this._onAfterBuild(context));
    return Column(
      children: [
       // 画面の 上端固定 位置取得用Widget(A)
        Container(
          heigth: 0.5,
          key: this._fixedTopPointerKey,
        ),
        Expanded(
          // ポイントタッチの操作に対してイベントを受信
          child: Listener(
            onPointerUp: (ev) {
              _setReadedFlg();
            },
            onPointerDown: (ev) {
              _setReadedFlg();
            },
            onPointerMove: (ev) {
              _setReadedFlg();
            },
            child: ListView.builder(
              controller: this._scrollController,
              physics: AlwaysScrollableScrollPhysics(),
              itemBuilder: (BuildContext context, index) {
                return Container(
                  child: this.dataCardList[index],
                );
              },
              itemCount: this.dataCardList.length,
            ),
          ),
        ),
        // 画面の 下端固定 位置取得用Widget(A)
        Container(
          heigth: 0.5,
          key: this._fixedBottomPointerKey,
        ),
      ],
    );
  }
}
```

### DataCard Widget
```dart
// ./lib/widgets/data_card_widget.dart

import 'package:flutter/material.dart';

// DataCard WidgetClass
class DataCard extends StatelessWidget {
  DataCard({@required this.data});
  final Map data;

  GlobalKey topPointerKey = new GlobalKey();
  GlobalKey bottomPointerKey = new GlobalKey();

  bool _top_displayed_flg = false;
  bool _bottom_displayed_flg = false;

  // 省略

  void isDisplayed() {
    return this._top_displayed_flg && this._top_displayed_flg;
  }

  void setTopDisplayed() {
    this._top_displayed_flg = true;
  }

  void setBottomDisplayed() {
    this._bottom_displayed_flg = true;
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // カードの 上端固定 位置取得用Widget(B)
        Container(
          key: this.topPointerKey,
          height: 0.5,
        ),
        Card(
          child: Text(this.data.name),
        ),
        // カードの 下端固定 位置取得用Widget(B)
        Container(
          key: this.bottomPointerKey,
          height: 0.5,
        ),
      ],
    );
  }
}

```

# 動作
 ※ 確認のため最初からスクロールが真ん中に配置されるようにしました。
 ※ 確認のため読んだことが緑に変わるようにしました。
![Dec-20-2020 20-48-53.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108364/067120e2-008a-f79f-bfc8-d126d1b57f88.gif)



# まとめ
このような面白いFlutterは最近人気ですね！
![image](https://user-images.githubusercontent.com/11989096/102032029-17c7fc80-3dfb-11eb-9f09-0be0d9c95184.png)
そのFlutterもAdvent Calendar 2020で盛り上がっています。
興味深い色んな話がありますので、ぜひ読んでみてください。
Flutter Advent Calendar 2020 ([#1](https://qiita.com/advent-calendar/2020/flutter), [#2](https://qiita.com/advent-calendar/2020/flutter-2), [#3](https://qiita.com/advent-calendar/2020/flutter-3))