【Flutter】ListViewで無限スクロールとPull to Refreshを実装

  • 2024年10月22日
  • 2024年10月22日
  • 小物

はじめに

Flutterを使用してアプリを開発する際、リスト表示は非常によく使われるUI要素の一つです。特に、チャットアプリやSNSのタイムラインのように、新しいデータや過去のデータを動的にロードする必要がある場合、ListViewをどのように実装するかが重要になります。本記事では、新しいデータと古いデータを動的にロードできるListViewの実装方法について解説します。

目的

  • 新しいデータのロード: ユーザーがリストを下に引っ張ると、新しいデータがロード(Pull-to-Refresh)され、リストの先頭に追加されます。
  • 古いデータのロード: ユーザーがリストの末尾までスクロールすると、過去のデータが自動的にロードされ、リストの末尾に追加されます。

実装のポイント

1. データの管理

データはStatefulWidgetのStateクラスで管理します。これにより、データの変更があった場合にsetStateでUIを更新できます。もしくは、適切に状態管理パッケージをご使用ください。

class _RefreshListViewState extends State<RefreshListView> {
  var _data = <String>[];
  // その他のコード...
}

2. データの取得

MockRepositoryクラスを使って、新しいデータと古いデータを取得します。これは実際のAPI呼び出しに置き換えることができます。

class MockRepository {
  Future<List<String>> fetchOldData() async {
    // 過去のデータを取得
  }

  Future<List<String>> fetchNewData() async {
    // 新しいデータを取得
  }
}

3. 新しいデータのロード(Pull to Refresh)

RefreshIndicatorウィジェットを使用して、ユーザーがリストを下に引っ張ったときに新しいデータをロードします。

@override
Widget build(BuildContext context) {
  return RefreshIndicator(
    onRefresh: _fetchNewData,
    child: ListView.builder(
      // リストビューの構築
    ),
  );
}

4. 古いデータのロード(Infinite Scroll)

NotificationListener<ScrollEndNotification>を使用して、ユーザーがリストの末尾までスクロールしたことを検知(notification.metrics.extentAfter == 0)し、古いデータをロードします。

child: NotificationListener<ScrollEndNotification>(
  onNotification: (notification) {
    if (notification.metrics.extentAfter == 0) {
      _fetchOldData();
    }
    return false;
  },
  child: ListView.builder(
    // リストビューの構築
  ),
),

5. ローディング状態の管理

データのロード中であることをユーザーに示すために、CircularProgressIndicatorをリスト内に表示します。そのためローディング中は、データより1つ多くリストを作成します

itemCount: dataLength + (_isLoadingOldData ? 1 : 0),
itemBuilder: (context, index) {
  if (_isLoading && index == _data.length) {
    return const Center(child: CircularProgressIndicator());
  }
  // データの表示
},

6. データの追加

新しいデータも古いデータも読み込みに行って、読み込んだデータをリスト用のデータに追加します。

  Future<void> _fetchOldData() async {
    if (_isLoading) {
      return;
    }
    setState(() {
      _isLoadingOldData = true;
    });

    _repository.fetchOldData().then((olderData) {
      final list = [..._data, ...olderData];
      setState(() {
        _data = list;
        _isLoadingOldData = false;
      });
    });
  }

  Future<void> _fetchNewData() async {
    if (_isLoading) {
      return;
    }

    _isLoadingNewData = true;

    final newData = await _repository.fetchNewData();
    final list = [...newData.reversed, ..._data];

    setState(() {
      _data = list;
    });
    _isLoadingNewData = false;
  }

新しいデータと古いデータを読み込むメソッドがありますが、若干違うことにお気づきでしょうか。以下のような違いがあり、そのための対策です。

  • 古いデータ: 読み込んだデータは、リストの最後に追加する。またローディング中を表すCircularProgressIndicatorは自前で用意している。そのため、表示するのに画面の更新を示すためsetStateを使う必要がある。そしてsetStateでローディング中を管理しているため、非同期処理(thenを使用)にしている

  • 新しいデータ: 読み込んだデータは、リストの前に追加してます。またそのままだと順番が逆になるため、ひっくり返してます(ここら辺は、実際の要件による)。ローディング中のマークは、RefreshIndicator自体が
    もっています。onRefreshに指定した非同期処理が完了するまで表示します。そのためawaitで処理を待機して、処理中は表示させるようにしてます(thenで実施すると、thenの中の処理は別になり、ローディングを示す指定した非同期処理自体は早く終わる)。また、ステートの変更をFlutterフレームワークに通知する必要性がないので、ローディングのフラグはsetState()の外で変更しています。

Q&A

なぜかスクロールが最初に移動します

これは、リストビューが再構築される際にスクロール位置がリセットされてしまうために起こります。以下の点を確認してください。

  • キーの設定: リストビューにキーを設定して、ウィジェットの再構築時に状態が保持されるようにします。

    ListView.builder(
      key: PageStorageKey('list_view'),
      // その他のコード...
    ),
    
  • データの管理場所: リストのデータがStateクラスで管理されていることを確認します。そうでない場合、ウィジェットが再構築されるたびにデータが初期化されてしまいます。

  • 不要なsetStateの呼び出し: setStateを適切な場所でのみ呼び出すようにし、ウィジェットが過剰に再構築されないようにします。

まとめ

本記事では、新しいデータと古いデータを動的にロードできるListViewの実装方法について解説しました。Flutterで無限スクロールやPullToRefreshを実装する際の基本的な手法を理解することで、よりユーザーエクスペリエンスの高いアプリを開発することができます。

参考

全ソース

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: const RefreshListView(),
    );
  }
}

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

  @override
  State<RefreshListView> createState() => _RefreshListViewState();
}

class _RefreshListViewState extends State<RefreshListView> {
  var _data = <String>[];
  var _isLoadingNewData = false;
  var _isLoadingOldData = false;
  bool get _isLoading => _isLoadingOldData || _isLoadingNewData;

  final _repository = MockRepository();

  @override
  void initState() {
    super.initState();
    _fetchOldData();
  }

  Future<void> _fetchOldData() async {
    if (_isLoading) {
      return;
    }
    setState(() {
      _isLoadingOldData = true;
    });

    _repository.fetchOldData().then((olderData) {
      final list = [..._data, ...olderData];
      setState(() {
        _data = list;
        _isLoadingOldData = false;
      });
    });
  }

  Future<void> _fetchNewData() async {
    if (_isLoading) {
      return;
    }

    _isLoadingNewData = true;

    final newData = await _repository.fetchNewData();
    final list = [...newData.reversed, ..._data];

    setState(() {
      _data = list;
    });
    _isLoadingNewData = false;
  }

  @override
  Widget build(BuildContext context) {
    final dataLength = _data.length;
    return RefreshIndicator(
      onRefresh: _fetchNewData,
      child: NotificationListener<ScrollEndNotification>(
        onNotification: (ScrollEndNotification notification) {
          final isScrollToEnd = notification.metrics.extentAfter == 0;

          if (isScrollToEnd) {
            _fetchOldData();
          }
          return false;
        },
        child: ListView.builder(
          key: const PageStorageKey('list_view'),
          itemCount: dataLength + (_isLoadingOldData ? 1 : 0),
          itemBuilder: (context, index) {
            if (_isLoadingOldData && index == dataLength) {
              return const Center(child: CircularProgressIndicator());
            }
            return ListTile(title: Text(_data[index]));
          },
        ),
      ),
    );
  }
}

class MockRepository {
  int counterPlus = 0;
  int counterMinus = -1;

  Future<List<String>> fetchOldData() async {
    await Future.delayed(const Duration(seconds: 1));
    return List.generate(10, (_) => (counterPlus++).toString());
  }

  Future<List<String>> fetchNewData() async {
    await Future.delayed(const Duration(seconds: 1));
    return List.generate(3, (_) => (counterMinus--).toString());
  }
}