header image

枝折

Flutter で無限ローディングを実装してみる、ただし有限

CREATED: 2023 / 02 / 19 Sun

UPDATED: 2023 / 08 / 04 Sun

モバイルアプリでよくある下や上にスワイプしてローディングしていくやつを Flutter で実装してみました。

まず StatefulWidget を用意

class InfiniteLoading extends StatefulWidget {
  const InfiniteLoading({super.key});

  @override
  State<InfiniteLoading> createState() => _InfiniteLoadingState();
}

class _InfiniteLoadingState extends State<InfiniteLoading> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.cyan,
      appBar: AppBar(
        backgroundColor: Colors.redAccent,
      ),
    );
  }
}

適当に色をつけて画面を作りました。 ここから始めます。

API の用意

まず、ローディングするにはデータが必要ですね 今回は PokeAPI を使おうと思います。 https://pokeapi.co/

以下エンドポイントに GET リクエストを出すことでヒトカゲからポケモンの一覧を取得することができます。 https://pokeapi.co/api/v2/pokemon

レスポンスは以下のようになっていて、20件ごとに取得することができます。

{
    "count": 1279,
    "next": "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
    "previous": null,
    "results": [
        {
            "name": "bulbasaur",
            "url": "https://pokeapi.co/api/v2/pokemon/1/"
        },
        .
        .
        .
        {
            "name": "raticate",
            "url": "https://pokeapi.co/api/v2/pokemon/20/"
        }
    ]
}

next に次のページを取得することができるエンドポイントが記載されているので、こちらを利用してどんどんデータを取得していきます。

http を利用したデータの取得

pubspec.ymlhttp を追加して早速 Flutter でデータを取得してみましょう。

dependencies:
  flutter:
    sdk: flutter

  http: ^0.13.5
void _fetchPokemon() async {
  http.Response _res = await http.get(
    Uri.https('pokeapi.co', '/api/v2/pokemon'),
  );

  print(_res.body);
  print(_res.statusCode);
}

// Scaffold の body に ElevatedButton を追加
Container(
  width: double.infinity,
  height: 100.0,
  child: ElevatedButton(
    onPressed: () {
      _fetchPokemon();
    },
    child: Text('GET POKEMON'),
  ),
)

ElevatedButton をタップするとポケモンのリストが以下のように表示されます。

flutter: {"count":1279,"next":"https://pokeapi.co/api/v2/pokemon?offset=20&limit=20","previous":null,"results":[{"name":"bulbasaur","url":"https://pokeapi.co/api/v2/pokemon/1/"},{"name":"ivysaur","url":"https://pokeapi.co/api/v2/pokemon/2/"},{"name":"venusaur","url":"https://pokeapi.co/api/v2/pokemon/3/"},{"name":"charmander","url":"https://pokeapi.co/api/v2/pokemon/4/"},{"name":"charmeleon","url":"https://pokeapi.co/api/v2/pokemon/5/"},{"name":"charizard","url":"https://pokeapi.co/api/v2/pokemon/6/"},{"name":"squirtle","url":"https://pokeapi.co/api/v2/pokemon/7/"},{"name":"wartortle","url":"https://pokeapi.co/api/v2/pokemon/8/"},{"name":"blastoise","url":"https://pokeapi.co/api/v2/pokemon/9/"},{"name":"caterpie","url":"https://pokeapi.co/api/v2/pokemon/10/"},{"name":"metapod","url":"https://pokeapi.co/api/v2/pokemon/11/"},{"name":"butterfree","url":"https://pokeapi.co/api/v2/pokemon/12/"},{"name":"weedle","url":"https://pokeapi.co/api/v2/pokemon/13/"},{"name":"kakuna","url":"https://pokeapi.co<…>
flutter: 200

このままではただの文字列のままなので、ウィジェット上でリスト化して扱うことができません。 なので、こちらを Pokemon のモデルに落とし込みます。

データモデルの作成

モデルはこんな感じにしておきます

class Pokemon {
  String name;

  Pokemon(this.name);

  factory Pokemon.fromJson(Map<String, dynamic> json) {
    final pokemon = Pokemon(json['name']);
    return pokemon;
  }
}

で、このモデルを利用して初期値をこしらえると、概ね以下のようになるかと思います。 _fetchPokemon メソッドでは引数に uri を取るようにして、次ページのリクエスト時にも柔軟に対応できるようにしています。

class _InfiniteLoadingState extends State<InfiniteLoading> {
  List<Pokemon> _pokemons = [];
  String? _nextPageLink;

  void _fetchPokemon(Uri uri) async {
    http.Response _res = await http.get(uri);

    Map<String, dynamic> data = jsonDecode(_res.body);

    List<dynamic> dd =
        data['results'].map((item) => Pokemon.fromJson(item)).toList();

    setState(() {
      _pokemons.addAll(dd.map((item) => item as Pokemon).toList());
      _nextPageLink = data['next'];
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.cyan,
      appBar: AppBar(
        backgroundColor: Colors.redAccent,
      ),
      body: Container(
        width: double.infinity,
        height: 100.0,
        child: ElevatedButton(
          onPressed: () {
            _fetchPokemon(Uri.https('pokeapi.co', '/api/v2/pokemon'));
          },
          child: Text('GET POKEMON'),
        ),
      ),
    );
  }
}

この画面で GET POKEMON のボタンをタップすると、_pokemons 変数の中に Pokemon モデルのデータが蓄積されていきます! あと、次のページ参照のためのリンクも _nextPageLink で残しておきましょう。

RefreshIndicator でリストのスワイプ時にロード機能をつける

次に、このデータたちを表示する ListView を用意し、これを RefreshIndicator で包み込みます。

_pokemons が空の時は初回ロードボタンとして GET POKEMON のボタンを配置し、 空ではなくなった時には RefreshIndicator でロードできるようにしています。

Scaffold(
  body: _pokemons.isEmpty // _pokemons が空かどうか判定
      ? Container(
          width: double.infinity,
          height: 100.0,
          child: ElevatedButton(
            onPressed: () {
              _fetchPokemon(Uri.https('pokeapi.co', '/api/v2/pokemon'));
            },
            child: Text('GET POKEMON'),
          ),
        )
      : RefreshIndicator(
          onRefresh: () async {
            // _nextPageLink がある時はそれを指定する
            if (_nextPageLink != null) {
              _fetchPokemon(Uri.parse(_nextPageLink!));
            } else {
              _fetchPokemon(Uri.https('pokeapi.co', '/api/v2/pokemon'));
            }
          },
          child: ListView.builder(
            itemCount: _pokemons.length,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text(_pokemons[index].name),
                tileColor: Colors.greenAccent,
              );
            },
          ),
        ),
)
無限ローディング

これで画面上部から下に画面をスワイプすることで、画面下部にデータが追加されるようになりましたが、 どちらかというと、スワイプした箇所からデータが表示されるようになってくれた方が嬉しいですよね。

画面の上部にデータを追加していく方法

画面上部に追加していく方法については非常に簡単で、ListView.builderreversetrue に設定するだけで良いです。

ListView.builder(
  reverse: true,
  itemCount: _pokemons.length,
  itemBuilder: (context, index) {
    return ListTile(
      title: Text(_pokemons[index].name),
      tileColor: Colors.greenAccent,
    );
  },
)

そうすると、Twitter のタイムラインのように、上へ上へとデータが追加されるようになります。

無限ローディング

では、下へのデータ追加についてはどうすれば良いのでしょうか。

画面の下部にデータを追加していく方法

以下のように実装すれば実現することができます。 細部は調整してください。

class _InfiniteLoadingState extends State<InfiniteLoading> {
  final ScrollController _controller = ScrollController(); // ListView 用の controller を初期化する
  bool _isLoading = false;
  List<Pokemon> _pokemons = [];
  String? _nextPageLink;

  @override
  void initState() {
    // 初期データを取得しておくことで、初回ロードボタンを省く
    _fetchPokemon(Uri.https('pokeapi.co', '/api/v2/pokemon'));

    _controller.addListener(() {
      if (_controller.position.maxScrollExtent == _controller.offset) {
        // ListView のスクロールが最下部に達するたびにこの処理がよばれる
        _add();
      }
    });

    super.initState();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _add() {
    // ここにはスクロールが最下部に達した時にしか来ないので、_nextPageLink が必須
    if (_nextPageLink != null) {
      _fetchPokemon(Uri.parse(_nextPageLink!));
    }
  }

  void _fetchPokemon(Uri uri) async {
    // ローディングの状態を API リクエストの前後で記録
    setState(() {
      _isLoading = true;
    });

    http.Response _res = await http.get(uri);

    Map<String, dynamic> data = jsonDecode(_res.body);

    List<dynamic> dd =
        data['results'].map((item) => Pokemon.fromJson(item)).toList();

    setState(() {
      _pokemons.addAll(dd.map((item) => item as Pokemon).toList());
      _nextPageLink = data['next'];
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.cyan,
      appBar: AppBar(
        backgroundColor: Colors.redAccent,
      ),
      body: ListView.builder(
        controller: _controller,
        itemCount: _pokemons.length + 1, // 画面下部に表示する Indicator の領域を確保
        itemBuilder: (context, index) {
          if (index + 1 == _pokemons.length + 1) { // リストの全ての要素の後、Indicator を表示する
            return Padding(
              padding: EdgeInsets.all(40.0),
              child: _isLoading
                  ? Center(
                      child: CircularProgressIndicator(),
                    )
                  : Container(),
            );
          }

          return ListTile(
            title: Text('${_pokemons[index].name}'),
            tileColor: Colors.greenAccent,
          );
        },
      ),
    );
  }
}

このように実装することで、ListView のスクロール最下部に達して API リクエストが始まると Indicator が一瞬表示され、 データが取得されるとページ下部にデータが追加されるようになります。

無限ローディング

を仕舞い

ざっとこんなところで以上、無限ローディング実装でした!

ソースコードはこちらのリポジトリからも参照できます。 yutaro1204 / flutter_list_view_loading_demo

main.darthome をそれぞれ切り替えて使ってみてください。

home: const DefaultInfiniteLoading(), // 下スワイプすると下に追加されるスクロール画面
home: const AscendingInfiniteLoading(), // 画面の上部にデータを追加していくスクロール画面
home: const DescendingInfiniteLoading(), // 画面の下部にデータを追加していくスクロール画面