Flutter の ListView / GridView で無限リストビュー

Flutter の ListView / GridView でいわゆる「無限リストビュー」を実装する方法を紹介します。アイテムを表示しつつ、末尾に到達したら次のアイテムを読み込んでいくやつです。

ListView / GridView には itemBuilder を受け取るコンストラクタがあります。

```dart
new ListView.builder(
  itemBuilder: (BuildContext context, int index) {
    // ここで index に応じた Widget を返す
    return new Text(index.toString());
  }
),
```

itemBuilder を使う場合は `null` を返したらリストビューの末尾であることを意味します。

つまり、以下のようにすることで、無限リストビューになります。

- 要素があるうちは Widget を返す
- 要素がなくなったら、次のアイテムを読み込む
- 新しくアイテムを読み込んだら、setState で build させる

よって、おおよそ以下のような感じになります。

```dart
itemBuilder: (BuildContext context, int index) {
  // List<String> items; というプロパティがあるとして
  var length = items?.length ?? 0;

  if (index == length) {
    // アイテム数を超えたので次のページを読み込む
    _load();
    
    // 画面にはローディング表示しておく
    return new Center(
      child: new Container(
        margin: const EdgeInsets.only(top: 8.0),
        width: 32.0,
        height: 32.0,
        child: const CircularProgressIndicator(),
      ),
    );
  } else if (index > length) {
    // ローディング表示より先は無し
    return null;
  }
  
  // アイテムがあるので返す
  return new Text(items[index]);
},
```


以下、コード全体。サンプルとして、GitHub の flutter/flutter の Issues をリスト表示しています。

```dart
import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';

import 'package:http/http.dart' as http;

void main() {
  runApp(new MaterialApp(
    home: new FooPage(),
  ));
}

class FooPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return new FooPageState();
  }
}

class FooPageState extends State<FooPage> {
  int page = 1;
  
  List<String> issues;
  
  bool loading = false;
  
  @override
  Widget build(BuildContext context) {
    var length = issues?.length ?? 0;
    return new Scaffold(
      appBar: new AppBar(title: new Text("Infinite")),
      body: new ListView.builder(
        itemBuilder: (BuildContext context, int index) {
          if (index == length) {
            // アイテム数を超えたので次のページを読み込む
            _load();
            
            // 画面にはローディング表示しておく
            return new Center(
              child: new Container(
                margin: const EdgeInsets.only(top: 8.0),
                width: 32.0,
                height: 32.0,
                child: const CircularProgressIndicator(),
              ),
            );
          } else if (index > length) {
            // ローディング表示より先は無し
            return null;
          }
          
          // アイテムがあるので返す
          var title = issues[index];
          return new Container(
            decoration: new BoxDecoration(
              border: new Border(
                bottom: new BorderSide(color: Colors.grey.shade300),
              ),
            ),
            child: new ListTile(
              key: new ValueKey<String>(title),
              title: new Text(title),
            ),
          );
        },
      ),
    );
  }
  
  Future<void> _load() async {
    if (loading) {
      return null;
    }
    loading = true;
    try {
      var url = "https://api.github.com/repositories/31792824/issues?page=${page}";
      var resp = await http.get(url);
      var data = json.decode(resp.body);
      setState(() {
        page += 1;
        if (data is List) {
          if (issues == null) {
            issues = <String>[];
          }
          data.forEach((dynamic elem) {
            if (elem is Map) {
              issues.add(elem['title'] as String);
            }
          });
        }
      });
    } finally {
      loading = false;
    }
  }
}
```

出来ました。

![](https://i.gyazo.com/9fd0e090403a3775929d3e4e9e4573aa.gif)