方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Flutter】ユーザー作成フレーズをシームレスにロードする無限スクロールの実装方法

flutter-inifinite-scroll
この記事は約43分で読めます。

はじめに

様々な方言を話すおしゃべり猫型ロボット「ミーア」を開発中

https://mia-cat.com

先日、オリジナルメッセージ機能(ユーザーが作成したオリジナルフレーズや録音した音声をミーアが話す)をリリースした。

https://mia-cat.com/notice/original-message-toc

ただ、ユーザーがフレーズを20個以上作成すると、作成したフレーズが見切れてしまうという考慮もれがあったので、フレーズリストの無限スクロール表示対応をしたいと思う。

現状の実装の問題点

サーバーサイドは20フレーズずつ取得ずみ

サーバーサイドは無限スクロール対応として、1リロードあたり20フレーズずつ取得している。

Go
// mia/http/user_phrase_handler.go

func (uph *UserPhraseHandler) HandleGetUserPhrases(c echo.Context) error {
	uid := c.Get("uid").(string)
	user, err := uph.userService.GetUser(uid)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"message": "User not found"})
	}

	// 無限スクロール対応として、1リロードあたり20フレーズずつ取得。
	page, err := strconv.Atoi(c.QueryParam("page"))
	if err != nil {
		page = 0
	}
	size, err := strconv.Atoi(c.QueryParam("size"))
	if err != nil {
		size = 20
	}

	phrases, err := uph.userPhraseService.GetUserPhrasesWithImported(user.ID, page, size)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"message": "Failed to get phrases"})
	}

	return c.JSON(http.StatusOK, phrases)
}

func (uph *UserPhraseHandler) HandleGetPublicPhrases(c echo.Context) error {
	uid := c.Get("uid").(string)
	user, err := uph.userService.GetUser(uid)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"message": "User not found"})
	}

	page, err := strconv.Atoi(c.QueryParam("page"))
	if err != nil {
		page = 0
	}
	size, err := strconv.Atoi(c.QueryParam("size"))
	if err != nil {
		size = 20
	}

	publicPhrases, err := uph.userPhraseService.GetPublicPhrases(user.ID, page, size)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"message": "Failed to get public phrases"})
	}

	return c.JSON(http.StatusOK, publicPhrases)
}

フロントエンド:フレーズのリストしか状態管理していない

アプリでは、フレーズ一覧として「あなたのフレーズ」と「公開フレーズ」の2つを用意している。あなたのフレーズは、文字通り自分がミーア本体に喋らせるように作成したフレーズで、公開フレーズは自分が作成したフレーズを公開ONにした場合に表示されるフレーズ。

他人が公開したフレーズも全て表示され、ユーザーは公開フレーズの中で気に入ったフレーズがあれば、自分のフレーズとして取り込むことができるというもの。

現在のUserPhraseNotifierPublicPhraseNotifierStateNotifier<List<UserPhraseWithSchedule>> を継承しており、状態としてフレーズのリストのみを管理している。しかし、無限スクロールを実装する際には、フレーズのリストに加えて「読み込み中かどうか」や「さらにデータがあるかどうか」といった追加の状態情報も管理する必要があるので、flutterの方は修正が必要。

現実装では、_hasMore_isLoading は内部的に管理されているが、StateNotifier の状態自体はフレーズのリスト (List<UserPhraseWithSchedule>) のみ。そのため、UI 側では isLoadinghasMore にアクセスできず、UserPhraseState クラスを使用している箇所でエラーが発生している。

Dart
class UserPhraseNotifier extends StateNotifier<List<UserPhraseWithSchedule>> {
  final ApiClient apiClient;
  int _currentPage = 0;
  bool _hasMore = true;
  bool _isLoading = false;

  UserPhraseNotifier(this.apiClient) : super([]);

  bool get isLoading => _isLoading;
  bool get hasMore => _hasMore;

  // その他のメソッド...
}

フロントエンド(Flutter)の修正ファイル

フロントエンドでは、主に以下のファイルを修正。

  1. user_phrase_notifier.dart
  2. public_phrase_notifier.dart
  3. phrase_list_screen.dart

状態管理の拡張(notifier.dart)

まず、user_phrase_notifier.dartpublic_phrase_notifier.dartを更新して、無限スクロールに必要な状態を管理できるようにする。具体的には、以下の追加情報を管理

  • isLoading: データをフェッチ中かどうか。
  • hasMore: さらにデータが存在するかどうか。

UserPhraseNotifierの更新

  • UserPhraseStateクラス: フレーズのリスト (phrases)、読み込み中かどうか (isLoading)、さらにデータがあるかどうか (hasMore) を一を管理するためのクラスを新しく定義。
  • StateNotifier の継承先を変更: StateNotifier<List<UserPhraseWithSchedule>> から StateNotifier<UserPhraseState> に変更し、より複雑な状態を管理できるようにする。
  • 状態の更新方法の変更: フレーズの追加、削除、更新時に state.copyWith を使って状態を更新する。これにより、UI が変更を正しく反映できる。
  • loadUserPhrasesメソッド: 指定したページのフレーズを取得し、状態を更新する。
  • loadMoreUserPhrasesメソッド: 次のページを読み込むためのメソッド。
  • resetメソッド: 状態を初期化。
Dart
// user_phrase_notifier.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:clocky_app/api/api_client.dart';
import 'package:clocky_app/api/user_phrase_with_schedule.dart';

class UserPhraseState {
  final List<UserPhraseWithSchedule> phrases;
  final bool isLoading;
  final bool hasMore;

  UserPhraseState({
    required this.phrases,
    required this.isLoading,
    required this.hasMore,
  });

  UserPhraseState copyWith({
    List<UserPhraseWithSchedule>? phrases,
    bool? isLoading,
    bool? hasMore,
  }) {
    return UserPhraseState(
      phrases: phrases ?? this.phrases,
      isLoading: isLoading ?? this.isLoading,
      hasMore: hasMore ?? this.hasMore,
    );
  }
}

class UserPhraseNotifier extends StateNotifier<UserPhraseState> {
  final ApiClient apiClient;
  int _currentPage = 0;
  final int _pageSize = 20;

  UserPhraseNotifier(this.apiClient)
      : super(UserPhraseState(phrases: [], isLoading: false, hasMore: true));

  Future<void> loadUserPhrases({int page = 0}) async {
    if (state.isLoading) return;

    state = state.copyWith(isLoading: true);

    try {
      final phrases = await apiClient.getUserPhrases(page: page, size: _pageSize);

      state = state.copyWith(
        phrases: page == 0 ? phrases : [...state.phrases, ...phrases],
        hasMore: phrases.length == _pageSize,
        isLoading: false,
      );

      _currentPage = page;
    } catch (e) {
      if (page == 0) {
        state = state.copyWith(phrases: []);
      }
      state = state.copyWith(isLoading: false);
      // エラーハンドリングを追加する場合はここに
      throw Exception('Failed to load user phrases: $e');
    }
  }

  Future<void> loadMoreUserPhrases() async {
    if (!state.hasMore || state.isLoading) return;
    final nextPage = _currentPage + 1;
    await loadUserPhrases(page: nextPage);
  }

  void reset() {
    _currentPage = 0;
    state = UserPhraseState(phrases: [], isLoading: false, hasMore: true);
  }

  // 他のメソッド(add, update, delete)も状態を更新するように修正する
  // 例: フレーズを追加した後にstate.phrasesを更新する
}

final userPhraseProvider =
    StateNotifierProvider<UserPhraseNotifier, UserPhraseState>(
  (ref) => UserPhraseNotifier(ref.watch(apiClientProvider)),
);

PublicPhraseNotifierの更新

  • PublicPhraseStateクラス: 公開フレーズのリスト、読み込み中かどうか、さらにデータがあるかどうかを管理。
  • loadPublicPhrasesメソッド: 指定したページの公開フレーズを取得し、状態を更新する。
  • loadMorePublicPhrasesメソッド: 次のページを読み込むためのメソッド。
  • resetメソッド: 状態を初期化。
Dart
// public_phrase_notifier.dart

import 'package:clocky_app/api/api_client.dart';
import 'package:clocky_app/api/user_public_phrase.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class PublicPhraseState {
  final List<UserPublicPhrase> publicPhrases;
  final bool isLoading;
  final bool hasMore;

  PublicPhraseState({
    required this.publicPhrases,
    required this.isLoading,
    required this.hasMore,
  });

  PublicPhraseState copyWith({
    List<UserPublicPhrase>? publicPhrases,
    bool? isLoading,
    bool? hasMore,
  }) {
    return PublicPhraseState(
      publicPhrases: publicPhrases ?? this.publicPhrases,
      isLoading: isLoading ?? this.isLoading,
      hasMore: hasMore ?? this.hasMore,
    );
  }
}

class PublicPhraseNotifier extends StateNotifier<PublicPhraseState> {
  final ApiClient apiClient;
  int _currentPage = 0;
  final int _pageSize = 20;

  PublicPhraseNotifier(this.apiClient)
      : super(PublicPhraseState(publicPhrases: [], isLoading: false, hasMore: true));

  Future<void> loadPublicPhrases({int page = 0}) async {
    if (state.isLoading) return;

    state = state.copyWith(isLoading: true);

    try {
      final publicPhrases = await apiClient.getPublicPhrases(page: page, size: _pageSize);

      state = state.copyWith(
        publicPhrases: page == 0 ? publicPhrases : [...state.publicPhrases, ...publicPhrases],
        hasMore: publicPhrases.length == _pageSize,
        isLoading: false,
      );

      _currentPage = page;
    } catch (e) {
      if (page == 0) {
        state = state.copyWith(publicPhrases: []);
      }
      state = state.copyWith(isLoading: false);
      throw Exception('Failed to load public phrases: $e');
    }
  }

  Future<void> loadMorePublicPhrases() async {
    if (!state.hasMore || state.isLoading) return;
    final nextPage = _currentPage + 1;
    await loadPublicPhrases(page: nextPage);
  }

  void reset() {
    _currentPage = 0;
    state = PublicPhraseState(publicPhrases: [], isLoading: false, hasMore: true);
  }

  // 他のメソッド(importなど)も状態を更新するように修正する
}

final publicPhraseProvider =
    StateNotifierProvider<PublicPhraseNotifier, PublicPhraseState>(
  (ref) => PublicPhraseNotifier(ref.watch(apiClientProvider)),
);

UI(phrase_list_screen.dart)の更新

次に、phrase_list_screen.dartを更新して、スクロールに応じて新しいデータを読み込むようにする。

  • ScrollControllerの追加: ユーザーがスクロールするたびに、リストの下部に近づいたかどうかをチェックする。
  • _onScrollメソッド: スクロール位置を監視し、リストの下部に近づいたら新しいデータを読み込む。
  • ListView.builderの更新: リストの最後に「ローディングインジケーター」を追加し、データが読み込まれている間に表示。
  • データの追加読み込み: loadMoreUserPhrasesloadMorePublicPhrasesを呼び出して、次のページのデータを取得。
Dart
// phrase_list_screen.dart

import 'package:clocky_app/api/public_phrase_notifier.dart';
import 'package:clocky_app/api/user_notifier.dart';
import 'package:clocky_app/api/user_phrase_notifier.dart';
import 'package:clocky_app/api/user_phrase_with_schedule.dart';
import 'package:clocky_app/screens/home/add_phrase_screen.dart';
import 'package:clocky_app/services/audio_storage_service.dart';
import 'package:clocky_app/styles/colors.dart';
import 'package:clocky_app/widgets/buttons.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

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

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('フレーズ一覧'),
          bottom: TabBar(
            indicator: const BoxDecoration(
              border: Border(
                bottom: BorderSide(
                  color: AppColors.primaryColor,
                  width: 2.0,
                ),
              ),
            ),
            tabs: [
              SizedBox(
                width: MediaQuery.of(context).size.width / 2,
                child: const Tab(
                  child: Text(
                    'あなたのフレーズ',
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ),
              SizedBox(
                width: MediaQuery.of(context).size.width / 2,
                child: const Tab(
                  child: Text(
                    '公開フレーズ',
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
        body: const TabBarView(
          children: [
            PhraseListYourPhraseTab(),
            PhraseListPublicPhraseTab(),
          ],
        ),
      ),
    );
  }
}

class PhraseListYourPhraseTab extends ConsumerStatefulWidget {
  const PhraseListYourPhraseTab({Key? key}) : super(key: key);

  @override
  _PhraseListYourPhraseTabState createState() =>
      _PhraseListYourPhraseTabState();
}

class _PhraseListYourPhraseTabState
    extends ConsumerState<PhraseListYourPhraseTab> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _scrollController.addListener(_onScroll);

    // 初回データのロード
    WidgetsBinding.instance.addPostFrameCallback((_) {
      ref.read(userPhraseProvider.notifier).loadUserPhrases();
    });
  }

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

  void _onScroll() {
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 200) {
      // リストの下部に近づいたら追加データをロード
      ref.read(userPhraseProvider.notifier).loadMoreUserPhrases();
    }
  }

  Future<void> _deletePhrase(int phraseId) async {
    try {
      await ref.read(userPhraseProvider.notifier).deleteUserPhrase(phraseId);
      // 削除後にリストを再ロード(必要に応じて)
      // await ref.read(userPhraseProvider.notifier).loadUserPhrases();
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('フレーズの削除に失敗しました: $e')),
      );
    }
  }

  Future<void> _navigateToEditPhraseScreen(
      UserPhraseWithSchedule phraseWithSchedule) async {
    // フレーズに関連する録音ファイルパスを取得
    String? recordedFilePath = await AudioStorageService.getRecordedFilePath(
        phraseWithSchedule.phrase.id);

    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => AddPhraseScreen(
          phraseWithSchedule: phraseWithSchedule,
          initialRecordedFilePath: recordedFilePath,
        ),
      ),
    ).then((_) {
      // 編集後にリストをリロード
      ref.read(userPhraseProvider.notifier).reset();
      ref.read(userPhraseProvider.notifier).loadUserPhrases();
    });
  }

  final Map<String, String> dayMap = {
    'Mon': '月',
    'Tue': '火',
    'Wed': '水',
    'Thu': '木',
    'Fri': '金',
    'Sat': '土',
    'Sun': '日'
  };

  final List<String> dayOrder = [
    'Mon',
    'Tue',
    'Wed',
    'Thu',
    'Fri',
    'Sat',
    'Sun'
  ];

  String translateDaysToJapanese(String days) {
    final dayList = days.split(",");
    dayList.sort((a, b) => dayOrder.indexOf(a).compareTo(dayOrder.indexOf(b)));
    return dayList.map((day) => dayMap[day] ?? day).join(', ');
  }

  Future<void> _navigateToAddPhraseScreen() async {
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => const AddPhraseScreen()),
    ).then((_) {
      // 追加後にリストをリロード
      ref.read(userPhraseProvider.notifier).reset();
      ref.read(userPhraseProvider.notifier).loadUserPhrases();
    });
  }

  @override
  Widget build(BuildContext context) {
    final userPhraseState = ref.watch(userPhraseProvider);
    final phrases = userPhraseState.phrases;
    final isLoading = userPhraseState.isLoading;
    final hasMore = userPhraseState.hasMore;
    final currentUser = ref.watch(userProvider);

    return Scaffold(
      body: phrases.isEmpty && isLoading
          ? const Center(child: CircularProgressIndicator())
          : phrases.isEmpty
              ? const Center(child: Text('まだフレーズがありません。'))
              : ListView.builder(
                  controller: _scrollController,
                  itemCount: phrases.length + (hasMore ? 1 : 0),
                  itemBuilder: (context, index) {
                    if (index < phrases.length) {
                      final phraseWithSchedule = phrases[index];
                      final daysInJapanese =
                          phraseWithSchedule.schedule != null &&
                                  phraseWithSchedule.schedule!.days != null
                              ? translateDaysToJapanese(
                                  phraseWithSchedule.schedule!.days!)
                              : null;
                      final isPrivate = phraseWithSchedule.phrase.isPrivate;
                      final isImportedAndPrivate =
                          phraseWithSchedule.phrase.userId != currentUser?.id &&
                              isPrivate;

                      Widget? subtitle;
                      if (phraseWithSchedule.schedule != null &&
                          phraseWithSchedule.schedule!.time != null &&
                          phraseWithSchedule.schedule!.days!.isNotEmpty) {
                        subtitle = Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(
                              '$daysInJapanese:${phraseWithSchedule.schedule!.time}',
                              style: const TextStyle(
                                color: AppColors.secondaryTextColor,
                                fontSize: 14.0,
                              ),
                            ),
                            if (isImportedAndPrivate)
                              const Text(
                                'このフレーズは非公開化されましたので使用できません',
                                style: TextStyle(
                                  color: Colors.red,
                                  fontSize: 14.0,
                                ),
                              ),
                          ],
                        );
                      } else if (isImportedAndPrivate) {
                        subtitle = const Text(
                          'このフレーズは非公開化されましたので使用できません',
                          style: TextStyle(
                            color: Colors.red,
                            fontSize: 14.0,
                          ),
                        );
                      }

                      return Column(
                        children: [
                          ListTile(
                            title: Text(
                              phraseWithSchedule.phrase.phrase,
                              style: const TextStyle(
                                color: AppColors.textColor,
                              ),
                            ),
                            subtitle: subtitle,
                            onTap: () {
                              _navigateToEditPhraseScreen(phraseWithSchedule);
                            },
                            trailing: Row(
                              mainAxisSize: MainAxisSize.min,
                              children: [
                                IconButton(
                                  icon: const Icon(Icons.edit),
                                  onPressed: () {
                                    _navigateToEditPhraseScreen(
                                        phraseWithSchedule);
                                  },
                                ),
                                IconButton(
                                  icon: const Icon(Icons.delete),
                                  onPressed: () {
                                    _deletePhrase(phraseWithSchedule.phrase.id);
                                  },
                                ),
                              ],
                            ),
                          ),
                          const Divider(
                            color: AppColors.disabledColor,
                            thickness: 0.5,
                            height: 8,
                          ),
                        ],
                      );
                    } else {
                      // ローディングインジケーター
                      return const Padding(
                        padding: EdgeInsets.symmetric(vertical: 16.0),
                        child: Center(child: CircularProgressIndicator()),
                      );
                    }
                  },
                ),
      bottomNavigationBar: Padding(
        padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 48.0),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text(
              '※ミーアの頭をタッチした時には、スケジュール設定されていないフレーズがランダムに再生されます',
              style: TextStyle(
                fontSize: 12.0,
                color: AppColors.secondaryTextColor,
              ),
            ),
            const SizedBox(height: 8.0),
            AppButton(
              text: 'フレーズを追加',
              onPressed: _navigateToAddPhraseScreen,
            ),
          ],
        ),
      ),
    );
  }
}

class PhraseListPublicPhraseTab extends ConsumerStatefulWidget {
  const PhraseListPublicPhraseTab({Key? key}) : super(key: key);

  @override
  _PhraseListPublicPhraseTabState createState() =>
      _PhraseListPublicPhraseTabState();
}

class _PhraseListPublicPhraseTabState
    extends ConsumerState<PhraseListPublicPhraseTab> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _scrollController.addListener(_onScroll);

    // 初回データのロード
    WidgetsBinding.instance.addPostFrameCallback((_) {
      ref.read(publicPhraseProvider.notifier).loadPublicPhrases();
    });
  }

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

  void _onScroll() {
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 200) {
      // リストの下部に近づいたら追加データをロード
      ref.read(publicPhraseProvider.notifier).loadMorePublicPhrases();
    }
  }

  Future<void> _importPublicPhrase(int phraseId) async {
    try {
      await ref.read(userPhraseProvider.notifier).importPublicPhrase(phraseId);
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('フレーズを取り込みました')),
      );
      // リストを再ロード
      ref.read(publicPhraseProvider.notifier).reset();
      ref.read(publicPhraseProvider.notifier).loadPublicPhrases();
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('フレーズの取り込みに失敗しました: $e')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final publicPhraseState = ref.watch(publicPhraseProvider);
    final publicPhrases = publicPhraseState.publicPhrases;
    final isLoading = publicPhraseState.isLoading;
    final hasMore = publicPhraseState.hasMore;

    return Scaffold(
      body: publicPhrases.isEmpty && isLoading
          ? const Center(child: CircularProgressIndicator())
          : publicPhrases.isEmpty
              ? const Center(child: Text('公開フレーズがありません。'))
              : ListView.builder(
                  controller: _scrollController,
                  itemCount: publicPhrases.length + (hasMore ? 1 : 0),
                  itemBuilder: (context, index) {
                    if (index < publicPhrases.length) {
                      final phrase = publicPhrases[index];

                      // isOwn を利用して、自分のフレーズかどうかを判定
                      final bool isOwn = phrase.isOwn;

                      // isImported を利用して、取り込んだフレーズかどうかを判定
                      final bool isImported = phrase.isImported;

                      return Column(
                        children: [
                          ListTile(
                            title: Text(phrase.phrase),
                            trailing: isOwn
                                ? null // 自分のフレーズなら「取り込む」ボタンを表示しない
                                : ElevatedButton(
                                    style: ElevatedButton.styleFrom(
                                      foregroundColor: Colors.white,
                                      backgroundColor: isImported
                                          ? AppColors.disabledColor
                                          : AppColors.primaryColor,
                                      disabledForegroundColor: Colors.grey,
                                      fixedSize: const Size(130, 40),
                                    ),
                                    onPressed: isImported
                                        ? null // 取り込み済みの場合は無効化
                                        : () {
                                            _importPublicPhrase(phrase.id);
                                          },
                                    child: Text(
                                      isImported ? '取り込み済み' : '取り込む',
                                      style: const TextStyle(
                                        fontWeight: FontWeight.bold,
                                        fontSize: 13.0,
                                        color: Colors.white,
                                      ),
                                    ),
                                  ),
                          ),
                          const Divider(
                            color: AppColors.disabledColor,
                            thickness: 0.5,
                            height: 8,
                          ),
                        ],
                      );
                    } else {
                      // ローディングインジケーター
                      return const Padding(
                        padding: EdgeInsets.symmetric(vertical: 16.0),
                        child: Center(child: CircularProgressIndicator()),
                      );
                    }
                  },
                ),
    );
  }
}

動作確認

ホーム画面→フレーズを作成するボタンをクリック→フレーズ一覧画面で、あなたのフレーズ、公開フレーズともに、20件以上のフレーズを作成。

作成したフレーズをスクロールすると、ローディングインジケーターが走り、次のフレーズが表示されるようになった。

現在の UserPhraseNotifier では、状態としてフレーズのリストのみを管理しており、追加の状態情報(isLoadinghasMore)が含まれていなかったため、UI 側でこれらの状態にアクセスしようとするとエラーが発生していた。

状態管理を拡張し、フレーズのリストに加えて isLoadinghasMore を含むクラス (UserPhraseState) を使用することで、UI 側でフレーズのリストが空かどうか、データがロード中かどうか、さらにデータが存在するかどうかを正しく判断できるようになり、無限スクロールできるようになった。

タイトルとURLをコピーしました