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

[Flutter] How to implement infinite scrolling to seamlessly load user-created phrases.

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

Introduction.

Talking cat-shaped robot “Mia” that speaks various dialects is under development.

https://mia-cat.com/en

We recently released an original message function (Mia speaks original phrases created by the user or recorded voice).

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

However, there was a lack of consideration that when a user creates more than 20 phrases, the created phrases are not visible, so we would like to support infinite scrolling display of the phrase list.

Problems with the current implementation

The server side has retrieved 20 phrases each.

The server side gets 20 phrases per reload to support infinite scrolling.

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)
}

Front end: only a list of phrases is state-managed.

The application provides two phrase lists: “Your Phrases” and “Public Phrases”. Your phrases are literally phrases that you have created to make the Mia main unit speak, and public phrases are phrases that are displayed when you have turned on public for the phrases you have created.

All phrases published by others are also displayed, and if users like any of the published phrases, they can import them as their own.

Currently, UserPhraseNotifier andPublicPhraseNotifier inherit from StateNotifier<List > and manage only a list of phrases as a state. However, when implementing infinite scrolling, it is necessary to manage additional state information such as “whether it is loading” or “whether there is more data” in addition to the list of phrases, so flutter needs to be modified.

In the current implementation, _hasMore and _isLoading are managed internally, but the StateNotifier state itself is only a list of phrases (List ). Therefore, isLoading and hasMore are not accessible on the UI side, and errors occur where the UserPhraseState class is used.

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

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

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

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

Front-end (Flutter) modified files

On the front end, the following files were mainly modified

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

Extended state management (notifier.dart)

First, update user_phrase_notifier.dart andpublic_phrase_notifier.dart to manage the necessary states for infinite scrolling. Specifically, manage the following additional information

  • isLoading: Whether data is being fetched or not.
  • hasMore: Whether more data exists.

Update UserPhraseNotifier

  • UserPhraseState class: Newly defined class to manage a list of phrases (phrases), whether they are loading (isLoading ), and whether there is more data (hasMore ).
  • ChangeStateNotifier inheritance: Change from StateNotifier<List<UserPhraseWithSchedule>> to StateNotifier<UserPhraseState> to manage more complex states. StateNotifier<UserPhraseWithSchedule>> to StateNotifier<UserPhraseState> to allow more complex state management.
  • Change the way state is updated: use state.copyWith to update state when adding, deleting, or updating phrases. This allows the UI to correctly reflect changes.
  • loadUserPhrases method: Retrieves phrases of the specified page and updates the status.
  • loadMoreUserPhrases method: Method to load the next page.
  • reset method: initializes the state.
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 phrases;
  final bool isLoading;
  final bool hasMore;

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

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

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

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

  Future 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 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(
  (ref) => UserPhraseNotifier(ref.watch(apiClientProvider)),
);

Update PublicPhraseNotifier

  • PublicPhraseState class: manages the list of public phrases, whether they are being read or not, and whether more data is available.
  • loadPublicPhrases method: Retrieves the public phrases of the specified page and updates its status.
  • loadMorePublicPhrases method: Method to load the next page.
  • reset method: initializes the state.
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 publicPhrases;
  final bool isLoading;
  final bool hasMore;

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

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

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

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

  Future 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 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(
  (ref) => PublicPhraseNotifier(ref.watch(apiClientProvider)),
);

Update UI ( phrase_list_screen.dart )

Next, update phrase_list_screen.dart to load new data as it scrolls.

  • Add a ScrollController: each time the user scrolls, check to see if they are near the bottom of the list.
  • _onScroll method: monitors the scroll position and loads new data when approaching the bottom of the list.
  • Update to ListView.builder: added a “loading indicator” at the end of the list to show while data is being loaded.
  • Additional data loading: call loadMoreUserPhrases orloadMorePublicPhrases to get data for the next page.
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 {
  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 _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 _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 dayMap = {
    'Mon': '月',
    'Tue': '火',
    'Wed': '水',
    'Thu': '木',
    'Fri': '金',
    'Sat': '土',
    'Sun': '日'
  };

  final List 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 _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 
      _PhraseListPublicPhraseTabState();
}

class _PhraseListPublicPhraseTabState
    extends ConsumerState {
  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 _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()),
                      );
                    }
                  },
                ),
    );
  }
}

operation check

Home screen → Click on the Create Phrase button → On the phrase list screen, create at least 20 phrases, both your phrases and public phrases.

When scrolling through the phrases created, the loading indicator now runs and the next phrase is displayed.

The current UserPhraseNotifier managed only a list of phrases as states and did not include additional state information ( isLoading, hasMore ), which caused errors when trying to access these states on the UI side.

Extended state management, using a class (UserPhraseState ) that includes isLoading and hasMore in addition to the list of phrases, so that the UI can correctly determine if the list of phrases is empty, if data is loading, and if data exists. The UI can now correctly determine if the list of phrases is empty if data is being loaded, and if data exists, allowing for infinite scrolling.

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