【Flutter】ユーザー作成フレーズをシームレスにロードする無限スクロールの実装方法
はじめに
先日、オリジナルメッセージ機能(ユーザーが作成したオリジナルフレーズや録音した音声をミーアが話す)をリリースした。
ただ、ユーザーがフレーズを20個以上作成すると、作成したフレーズが見切れてしまうという考慮もれがあったので、フレーズリストの無限スクロール表示対応をしたいと思う。
現状の実装の問題点
サーバーサイドは20フレーズずつ取得ずみ
サーバーサイドは無限スクロール対応として、1リロードあたり20フレーズずつ取得している。
// 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にした場合に表示されるフレーズ。
他人が公開したフレーズも全て表示され、ユーザーは公開フレーズの中で気に入ったフレーズがあれば、自分のフレーズとして取り込むことができるというもの。
現在のUserPhraseNotifier
とPublicPhraseNotifier
はStateNotifier<List<UserPhraseWithSchedule>>
を継承しており、状態としてフレーズのリストのみを管理している。しかし、無限スクロールを実装する際には、フレーズのリストに加えて「読み込み中かどうか」や「さらにデータがあるかどうか」といった追加の状態情報も管理する必要があるので、flutterの方は修正が必要。
現実装では、_hasMore
や _isLoading
は内部的に管理されているが、StateNotifier
の状態自体はフレーズのリスト (List<UserPhraseWithSchedule>
) のみ。そのため、UI 側では isLoading
や hasMore
にアクセスできず、UserPhraseState
クラスを使用している箇所でエラーが発生している。
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)の修正ファイル
フロントエンドでは、主に以下のファイルを修正。
user_phrase_notifier.dart
public_phrase_notifier.dart
phrase_list_screen.dart
状態管理の拡張(notifier.dart)
まず、user_phrase_notifier.dart
とpublic_phrase_notifier.dart
を更新して、無限スクロールに必要な状態を管理できるようにする。具体的には、以下の追加情報を管理
- isLoading: データをフェッチ中かどうか。
- hasMore: さらにデータが存在するかどうか。
UserPhraseNotifier
の更新
-
UserPhraseStateクラス: フレーズのリスト (
phrases
)、読み込み中かどうか (isLoading
)、さらにデータがあるかどうか (hasMore
) を一を管理するためのクラスを新しく定義。 -
StateNotifier
の継承先を変更:StateNotifier<List<UserPhraseWithSchedule>>
からStateNotifier<UserPhraseState>
に変更し、より複雑な状態を管理できるようにする。 -
状態の更新方法の変更: フレーズの追加、削除、更新時に
state.copyWith
を使って状態を更新する。これにより、UI が変更を正しく反映できる。 - loadUserPhrasesメソッド: 指定したページのフレーズを取得し、状態を更新する。
- loadMoreUserPhrasesメソッド: 次のページを読み込むためのメソッド。
- resetメソッド: 状態を初期化。
// 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メソッド: 状態を初期化。
// 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)),
);
phrase_list_screen.dart
)の更新
UI(次に、phrase_list_screen.dart
を更新して、スクロールに応じて新しいデータを読み込むようにする。
- ScrollControllerの追加: ユーザーがスクロールするたびに、リストの下部に近づいたかどうかをチェックする。
- _onScrollメソッド: スクロール位置を監視し、リストの下部に近づいたら新しいデータを読み込む。
- ListView.builderの更新: リストの最後に「ローディングインジケーター」を追加し、データが読み込まれている間に表示。
-
データの追加読み込み:
loadMoreUserPhrases
やloadMorePublicPhrases
を呼び出して、次のページのデータを取得。
続きはこちらで記載しています
Discussion