FlutterでiOS/Android対応のプルトゥリフレッシュ機能を作成する方法
はじめに
アプリでデータを更新したい時、画面を下にスワイプしてリフレッシュしたことはありませんか?
例えば、TwitterやInstagramで新しい投稿を読み込む時、メールアプリで新しいメールを確認する時など、多くのアプリで使われている機能です。
このようなプルトゥリフレッシュ機能(画面を下にスワイプしてデータを更新する機能)をFlutterで実装したいと思ったことはありませんか?
実は、FlutterではRefreshIndicator.adaptiveというウィジェットを使うことで、iOSとAndroidそれぞれのOSに最適化されたデザインで、簡単にリフレッシュ機能を実装できます。
RefreshIndicator.adaptiveとは?
RefreshIndicator.adaptiveは、画面を下にスワイプした時にデータを更新するウィジェットです。
主な特徴:
- iOS: スピナーが中央に表示され、iOSらしいデザイン
- Android: マテリアルデザインの円形プログレスインジケータ
- 自動的にOSを検出して適切なデザインを適用
- スワイプの動作を自動で検知
- 非同期処理(データ取得など)に対応
基本的な使い方
1. 最もシンプルな実装例
まずは、最もシンプルなRefreshIndicator.adaptiveの実装例を見てみましょう:
RefreshIndicator.adaptive(
onRefresh: () async {
// データを更新する処理
await Future.delayed(const Duration(seconds: 2));
},
child: ListView(
children: [
ListTile(title: Text('アイテム 1')),
ListTile(title: Text('アイテム 2')),
ListTile(title: Text('アイテム 3')),
],
),
)
このコードで、リストを下にスワイプすると、2秒間のローディング後にリフレッシュが完了します。
2. 実際のデータ更新処理
実際のアプリでは、APIからデータを取得する処理を実装します:
class _MyPageState extends State<MyPage> {
List<String> items = ['アイテム 1', 'アイテム 2', 'アイテム 3'];
// データを更新する処理
Future<void> _refreshData() async {
// 実際のアプリでは、ここでAPIからデータを取得
await Future.delayed(const Duration(seconds: 2));
setState(() {
// 新しいデータでリストを更新
items = [
'新しいアイテム ${DateTime.now().second}',
'新しいアイテム ${DateTime.now().second + 1}',
'新しいアイテム ${DateTime.now().second + 2}',
];
});
}
@override
Widget build(BuildContext context) {
return RefreshIndicator.adaptive(
onRefresh: _refreshData,
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(items[index]),
);
},
),
);
}
}
RefreshIndicator.adaptiveの主なパラメータ
onRefresh(必須)
リフレッシュ時に実行される処理を指定します。必ずFutureを返す必要があります。
onRefresh: () async {
// データ更新処理
await fetchDataFromAPI();
}
child(必須)
リフレッシュ対象となるウィジェットを指定します。通常はScrollView系のウィジェット(ListView、GridView、SingleChildScrollViewなど)を使用します。
color
リフレッシュインジケータの色を指定します。
RefreshIndicator.adaptive(
color: Colors.blue,
onRefresh: () async { /* ... */ },
child: ListView(/* ... */),
)
backgroundColor
リフレッシュインジケータの背景色を指定します。
RefreshIndicator.adaptive(
backgroundColor: Colors.white,
onRefresh: () async { /* ... */ },
child: ListView(/* ... */),
)
strokeWidth
Androidでのプログレスインジケータの線の太さを指定します。
RefreshIndicator.adaptive(
strokeWidth: 3.0,
onRefresh: () async { /* ... */ },
child: ListView(/* ... */),
)
よく使うパターン
1. エラーハンドリング付きのリフレッシュ
Future<void> _refreshData() async {
try {
// データ取得処理
await fetchDataFromAPI();
setState(() {
// 成功時の処理
});
} catch (e) {
// エラー時の処理
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('エラーが発生しました: $e')),
);
}
}
2. カスタムインジケータ
RefreshIndicator.adaptive(
color: Colors.blue,
backgroundColor: Colors.white,
strokeWidth: 4.0,
onRefresh: () async {
await Future.delayed(const Duration(seconds: 2));
},
child: ListView(/* ... */),
)
3. 条件付きリフレッシュ
RefreshIndicator.adaptive(
onRefresh: _isLoading ? null : _refreshData, // ローディング中は無効化
child: ListView(/* ... */),
)
注意点
-
childは必ずScrollView系のウィジェットである必要があります
- ListView、GridView、SingleChildScrollViewなど
- ColumnやRowは使用できません
-
onRefreshは必ずFutureを返す必要があります
- async/awaitを使用するか、Futureを返す関数を指定
-
データが空の場合の対応
- リストが空の場合、リフレッシュができない場合があります
- その場合は、ListViewの高さを指定するか、SingleChildScrollViewを使用
-
ネットワークエラーの処理
- 実際のアプリでは、ネットワークエラーやタイムアウトの処理を必ず実装
完全な実装例
以下は、RefreshIndicator.adaptiveを使った完全な実装例です。
⚠️ DartPadでの注意:
DartPad上では、タッチやトラックパッドでのスクロールやプルダウンリフレッシュが正しく動作しない場合があります。
スクロールバーのドラッグ操作は可能ですが、実機やシミュレーターでの動作を推奨します。
// Flutterの基本パッケージをインポート
import 'package:flutter/material.dart';
/// アプリケーションのエントリーポイント
/// アプリが起動した時に最初に実行される関数
void main() {
runApp(const MyApp());
}
/// アプリケーションのルートウィジェット
/// MaterialAppを設定し、アプリ全体のテーマやナビゲーションを管理
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Refresh Indicator Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true, // Material 3デザインを使用
),
// デモページをホーム画面として設定
home: const RefreshIndicatorDemoPage(),
);
}
}
/// リフレッシュ機能のデモページ
/// RefreshIndicator.adaptiveの使い方を示すサンプル画面
class RefreshIndicatorDemoPage extends StatefulWidget {
const RefreshIndicatorDemoPage({super.key});
@override
State<RefreshIndicatorDemoPage> createState() => _RefreshIndicatorDemoPageState();
}
/// リフレッシュ機能のデモページの状態管理クラス
/// データの管理とリフレッシュ処理を担当
class _RefreshIndicatorDemoPageState extends State<RefreshIndicatorDemoPage> {
/// 表示するアイテムのリスト
List<String> _items = [];
/// ローディング状態を管理するフラグ
bool _isLoading = false;
/// 最後に更新された時刻
DateTime? _lastUpdated;
/// 初期化時にデータを読み込む
@override
void initState() {
super.initState();
_loadInitialData();
}
/// 初期データを読み込む処理
/// アプリ起動時に実行される
void _loadInitialData() {
setState(() {
_items = [
'🍎 りんご',
'🍌 バナナ',
'🍊 オレンジ',
'🍇 ぶどう',
'🍓 いちご',
];
_lastUpdated = DateTime.now();
});
}
/// データをリフレッシュする処理
/// ユーザーが画面を下にスワイプした時に実行される
Future<void> _refreshData() async {
setState(() {
_isLoading = true;
});
try {
// 実際のアプリでは、ここでAPIからデータを取得
// 今回はデモ用に2秒間の遅延を入れる
await Future.delayed(const Duration(seconds: 2));
// 新しいデータを生成(ランダムな果物を追加)
final fruits = ['🍎', '🍌', '🍊', '🍇', '🍓', '🍑', '🍒', '🥝', '🥭', '🍍'];
final random = DateTime.now().millisecondsSinceEpoch;
setState(() {
_items = List.generate(
5 + (random % 3), // 5-7個のアイテムを生成
(index) => '${fruits[random % fruits.length]} アイテム ${index + 1}',
);
_lastUpdated = DateTime.now();
_isLoading = false;
});
// 成功メッセージを表示
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('データを更新しました!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
}
} catch (e) {
// エラーが発生した場合の処理
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('エラーが発生しました: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 2),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
// アプリバーの設定
appBar: AppBar(
title: const Text('RefreshIndicator.adaptive デモ'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
elevation: 2,
),
body: Column(
children: [
// 最後に更新された時刻を表示するヘッダー
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: Colors.grey[100],
child: Column(
children: [
const Text(
'下にスワイプしてリフレッシュ',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (_lastUpdated != null) ...[
const SizedBox(height: 4),
Text(
'最終更新: ${_lastUpdated!.toString().substring(0, 19)}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
],
),
),
// メインのリスト部分
Expanded(
child: RefreshIndicator.adaptive(
// リフレッシュ時の処理
onRefresh: _refreshData,
// カスタムスタイル(オプション)
color: Colors.blue,
backgroundColor: Colors.white,
strokeWidth: 3.0,
// リフレッシュ対象のウィジェット
child: _items.isEmpty
? const Center(
child: Text('データがありません'),
)
: ListView.builder(
// リストの各アイテムを構築
itemCount: _items.length,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: ListTile(
// アイテムの内容を表示
title: Text(
_items[index],
style: const TextStyle(fontSize: 16),
),
// アイテム番号を表示
subtitle: Text('アイテム ${index + 1}'),
// 右側にアイコンを表示
trailing: const Icon(Icons.arrow_forward_ios),
// タップ時の処理
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${_items[index]} をタップしました'),
duration: const Duration(seconds: 1),
),
);
},
),
);
},
),
),
),
],
),
// フローティングアクションボタン(手動リフレッシュ用)
floatingActionButton: FloatingActionButton(
onPressed: _isLoading ? null : _refreshData,
tooltip: '手動リフレッシュ',
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Icon(Icons.refresh),
),
);
}
}
まとめ
RefreshIndicator.adaptiveを使用することで、iOSとAndroidそれぞれのOSに最適化されたデザインで、簡単にプルトゥリフレッシュ機能を実装できます。
主なポイント:
- adaptiveを使用することで、OSに応じた適切なデザインが自動適用される
- onRefreshには必ずFutureを返す処理を指定
- childにはScrollView系のウィジェットを指定
- エラーハンドリングを適切に実装する
- ユーザビリティを考慮したローディング時間の設定
Discussion