💡

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(/* ... */),
)

注意点

  1. childは必ずScrollView系のウィジェットである必要があります

    • ListView、GridView、SingleChildScrollViewなど
    • ColumnやRowは使用できません
  2. onRefreshは必ずFutureを返す必要があります

    • async/awaitを使用するか、Futureを返す関数を指定
  3. データが空の場合の対応

    • リストが空の場合、リフレッシュができない場合があります
    • その場合は、ListViewの高さを指定するか、SingleChildScrollViewを使用
  4. ネットワークエラーの処理

    • 実際のアプリでは、ネットワークエラーやタイムアウトの処理を必ず実装

完全な実装例

以下は、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