目次

目次

FlutterでRiverpodとScrollControllerを使用して追加読み込みを実装してみた

瀬川 亮
瀬川 亮
最終更新日2025/11/25 投稿日2025/10/07

はじめに

NX開発推進部プロダクト開発第1Gの瀬川です。 普段はAndroidアプリエンジニアとしてKotlinを使用した開発を行なっていますが、今年の5月末から新規プロジェクトのスマホアプリをFlutter で開発しています。 今回はそんなFlutterで新規アプリを開発する中で「追加読み込み機能」をFlutter/Dartで実装したためその知見を共有したいと思います。 Flutter開発を行っているエンジニアの力に少しでもなれれば良いです。

概要

今回実装した「追加読み込み機能」は至ってシンプルで、リストで表示しているデータが表示分が最後の方になったタイミングで新しく追加のデータを取得&追加して表示する機能となります。 YouTubeの動画一覧画面で実装されている機能と同様のものになります。

YouTubeの場合は次々と動画データが増えていきますが、今回は擬似的な再現として数字データを10ずつ追加していく形で実装を紹介します。

準備

開発環境

開発環境は以下になります。 IDE:Android Studio Meerkat | 2024.3.1 Patch 1 Flutter : 3.29.3 Dart : 3.7.2

使用技術

主に使用した技術は下記3つです。

  • ScrollController
    • ScrollControllerはユーザーのスクロール動作検知・スクロール位置取得などに使用します。ListView に登録すると、そのリスト内での現在のスクロール位置や残りスクロール距離を取得できスクロールイベントをフックできます。 
  • ListView(Flutter標準)
    • スクロール可能なリストを生成/表示するために使用。
  • Riverpod
    • DIや状態管理のために使用するライブラリ。今回は表示するリストデータを状態管理する目的として使用。

導入ライブラリ

実装

リストの状態管理をRiverpodで実装

まず初めに、RiverpodのNotifierを使用して表示するリストを状態管理するクラスを作成します。

Notifier

import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'list_notifier.g.dart';

class ListState {
  final List<int> listData;
  final bool isLoading;

  ListState({required this.listData, this.isLoading = false});

  ListState copyWith({List<int>? listData, bool? isLoading}) {
    return ListState(
      listData: listData ?? this.listData,
      isLoading: isLoading ?? this.isLoading,
    );
  }
}

@riverpod
class ListNotifier extends _$ListNotifier {
  int _currentMax = 0;

  @override
  ListState build() {
    return ListState(listData: _initialize());
  }

  // 初期生成: 1〜10 を作成
  List<int> _initialize() {
    _currentMax = 10;
    return List.generate(_currentMax, (i) => i + 1);
  }
}

ローディング状態も状態管理に含めたいため、表示するリストとbool値をプロパティに持つListStateクラスを作成し、ListNotifierの監視対象にします。 Providerを自動生成するため@riverpodをクラスの先頭に付与し、 part 〜;を忘れず記載し下記コマンドを実行してlist_notifier.g.dartファイルを自動生成します。

dart run build_runner build -d

※riverpod_annotation / riverpod_generateライブラリが必要になります。

UI

Notifierで生成されたリストをUIで表示するコードは下記になります。

class SampleScreen extends ConsumerWidget {
  const SampleScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ListNotifierを監視して、リストの状態を取得
    final state = ref.watch(listNotifierProvider);

    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: ListView(
          children:
              state.listData.map((item) {
                // リストをマッピングして各アイテムを表示
                return listItem(item.toString());
              }).toList(),
        ),
      ),
    );
  }

  // リストアイテムの共通ウィジェット
  Widget listItem(String item) {
    return Column(
      children: [
        Container(
          width: double.infinity,
          height: 200,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: Colors.grey,
            borderRadius: BorderRadius.circular(8.0),
          ),
          child: Text(
            item,
            style: TextStyle(fontSize: 24, color: Colors.white),
          ),
        ),
        SizedBox(height: 16.0),
      ],
    );
  }
}

ConsumerWidgetにすることで、上記で作成したNotifierをUIで利用するためのRefが使用できるようになります。 これによりプロバイダーの状態を監視し、状態が変化したときに自動で UI が更新されるようになります。 この段階でUIには1〜10のアイテムが表示されスクロールも可能ですが、これ以上のデータは表示されない状態となります。

list.gif

ScrollControllerを使ってスクロール監視

リストコンテンツの下部まで到達した時に追加読み込み処理を走らせるには、ユーザーのスクロール操作を検知する必要があるため、ScrollControllerを使用します。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'list_notifier.dart';

class SampleScreen extends ConsumerStatefulWidget {
  const SampleScreen({super.key});

  @override
  ConsumerState<SampleScreen> createState() => _SampleScreenState();
}

class _SampleScreenState extends ConsumerState<SampleScreen> {
  final ScrollController _scrollController = ScrollController();

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

    // スクロールコントローラーのリスナーを追加
    _scrollController.addListener(() {
      // スクロールが末尾近くになったら追加読み込み
      if (_scrollController.position.pixels >=
          _scrollController.position.maxScrollExtent - 100) {
        // ここに追加読み込みの処理を記述
      }
    });
  }

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

  @override
  Widget build(BuildContext context) {
    // ListNotifierを監視して、リストの状態を取得
    final state = ref.watch(listNotifierProvider);

    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: ListView(
          controller: _scrollController,
          children:
              state.listData.map((item) {
                // リストをマッピングして各アイテムを表示
                return listItem(item.toString());
              }).toList(),
        ),
      ),
    );
  }
 // 割愛
}

ScrollControllerを使用するにはStatefulWidgetを使用する必要があったため、この段階でConsumerStatefulWidgetに変更しています。

_scrollController.addListener(() {

ScrollControllerのaddListenerを使用することで、ユーザーがスクロール操作を行った時に呼ぶ処理を登録できます。 今回はinitStateでStateの初期化を実行するタイミングで、ScrollControllerのリスナーに特定条件下での追加読み込み処理を登録することで、リスト最下部付近に到達した際に追加読み込みを実行するようにします。

_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 100
  • _scrollController.position.pixels:現在のスクロール位置
  • _scrollController.position.maxScrollExtent:スクロール可能な最大位置(一番下) となります。 ユーザーのスクロール位置(画面下)がリスト最下部から100ピクセル上部の位置を超えた時点で追加読み込み処理を実行しています。 _scrollController.position.maxScrollExtent(リスト最下部)で追加読み込みを実行するのはユーザーが求めているデータの表示が遅くUXとして良くないため、リスト最下部付近まで到達したら追加読み込みを実行するようにしています。
ListView(
    controller: _scrollController,

また、このScrollControllerを実際のスクロール時の挙動として適用させるためには、スクロール挙動を実装したいListViewのcontrollerパラメーターにScrollControllerインスタンスを渡す必要があります。

試しに背景色を変更してみる

// 初期背景色を設定
Color backgroundColor = Colors.white;

// スクロールコントローラーのリスナーを追加
_scrollController.addListener(() {
  if (_scrollController.position.pixels >=
       _scrollController.position.maxScrollExtent - 100) {
    // backgroundColorを変更
    setState(() {
      backgroundColor = Colors.blue;
    });
  }
});

試しにリスト下部に到達した時に画面の背景色を変換させるような処理をリスナーに登録した場合下記のような動作になります。

scroll_color_change.gif

追加読み込み処理を実装

スクロールに応じて処理を呼び出すことはできたので、あとはNotifier内に追加データの読み込み処理を実装し、UI側で呼び出すだけです。

Notifier

// 追加生成: 次の 10 件を追加
  void loadMore() {
    // すでにロード中なら何もしない
    if (state.isLoading) return;

    state = state.copyWith(isLoading: true);

    Future.delayed(const Duration(milliseconds: 1000), () {
      final newData = List.generate(10, (i) => _currentMax + i + 1);
      _currentMax += 10;
      state = state.copyWith(
        listData: [...state.listData, ...newData],
        isLoading: false,
      );
    });
  }

初めにローディングフラグをtrueにしUIでローディング表示をするよう状態を更新します。 今回はリストにデータが追加されている事をわかりやすくするため、あえて1秒遅延かけてデータ追加をしています。 その後、Notifierで状態管理しているリストを、現在のリスト(state)と新しいデータリスト(newItems)を合わせる形でstate = で更新します。 _currentMaxで現在までにリストに追加された最大の値を保持することで、新しいデータを生成する際の基準として使用します。

UI

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'list_notifier.dart';

class SampleScreen extends ConsumerStatefulWidget {
  const SampleScreen({super.key});

  @override
  ConsumerState<SampleScreen> createState() => _SampleScreenState();
}

class _SampleScreenState extends ConsumerState<SampleScreen> {
  final ScrollController _scrollController = ScrollController();
  Color backgroundColor = Colors.white;

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

    // スクロールコントローラーのリスナーを追加
    _scrollController.addListener(() {
      // スクロールが末尾近くになったら追加読み込み
      if (_scrollController.position.pixels >=
          _scrollController.position.maxScrollExtent - 100) {
        ref.read(listNotifierProvider.notifier).loadMore();
      }
    });
  }

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

  @override
  Widget build(BuildContext context) {
    // ListNotifierを監視して、リストの状態を取得
    final state = ref.watch(listNotifierProvider);

    return Scaffold(
      backgroundColor: backgroundColor,
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: ListView.builder(
          controller: _scrollController,
          itemCount: state.listData.length + (state.isLoading ? 1 : 0),
          itemBuilder: (context, index) {
            if (index < state.listData.length) {
              return listItem(state.listData[index].toString());
            } else {
              // ロード中インジケーター
              return const Padding(
                padding: EdgeInsets.symmetric(vertical: 40),
                child: Center(child: CircularProgressIndicator()),
              );
            }
          },
        ),
      ),
    );
  }

  // リストアイテムの共通ウィジェット
  Widget listItem(String item) {
    return Column(
      children: [
        Container(
          width: double.infinity,
          height: 200,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: Colors.grey,
            borderRadius: BorderRadius.circular(8.0),
          ),
          child: Text(
            item,
            style: TextStyle(fontSize: 24, color: Colors.white),
          ),
        ),
        SizedBox(height: 16.0),
      ],
    );
  }
}

データローディング中は現在のデータリストの末尾にローディングインジケータを表示したいので、ListView.builderを使用し生成itemのindexを取得して条件分岐を行っています。

ListView.builder(
    controller: _scrollController,
    itemCount: state.listData.length + (state.isLoading ? 1 : 0),
    itemBuilder: (context, index) {
      if (index < state.listData.length) {
        return listItem(state.listData[index].toString());
      } else {
        // ロード中インジケーター
        return const Padding(
          padding: EdgeInsets.symmetric(vertical: 40),
          child: Center(child: CircularProgressIndicator()),
        );
      }
   },
)

これで1秒後に状態管理しているリストは追加分データを含んだ新しいリストになり、UIもその更新を検知し新しいリストが表示されることで、追加読み込み機能が完成しました。

完成品

完成したアプリの挙動がこちらです。

Screen_recording_20250821_221703.gif

今回は数字リストの生成で追加データを擬似的に再現しましたが、実際のアプリでは追加データをAPI取得する処理を呼び出しています。

まとめ

今回はFlutterでRiverpodとScrollControllerを併用し、ユーザーのスクロール位置に応じて追加データを読み込む機能を作成しました。

今回のようにScrollControllerはユーザーのスクロールに応じてさまざまな機能を提供する事ができるため、その他機能の実装でも活躍できそうと感じました。

「追加読み込み機能」はボタン押下・次ページ遷移などをせず、追加データをUIに反映する事ができるため、UXの観点から良い体験をユーザーに与える事ができます。

皆さんも開発/運用中のアプリ内のどこかでぜひ一度実装してみてください。

瀬川 亮

目次