はじめに
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を実装する際の基本的な手法を理解することで、よりユーザーエクスペリエンスの高いアプリを開発することができます。
参考
- RefreshIndicator(Flutter 今週のウィジェット)
- 【Flutter】RefreshIndicator活用法!スムーズな更新を実現
RefreshIndicatorのカスタム方法など詳しく知りたい方はこちら。
全ソース
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());
}
}