👋

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

2024/10/10に公開

はじめに

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

https://mia-cat.com

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

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

ただ、ユーザーがフレーズを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にした場合に表示されるフレーズ。

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

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

現実装では、_hasMore_isLoading は内部的に管理されているが、StateNotifier の状態自体はフレーズのリスト (List<UserPhraseWithSchedule>) のみ。そのため、UI 側では isLoadinghasMore にアクセスできず、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)の修正ファイル

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

  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メソッド: 状態を初期化。
// 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)),
);

UI(phrase_list_screen.dart)の更新

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

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

続きはこちらで記載しています
https://kazulog.fun/dev/flutter-inifinite-scroll/

Discussion