【Flutter】Pentagonで利用しているPaginationListについてのまとめ

こんにちは、株式会社Pentagonでアプリ開発をしている石渡港です。
https://pentagon.tokyo

今回はPentagonで利用しているFlutterのPaginationListについてまとめました。
その結果、PaginationListについての整理ができて、より深く理解できるようになりました。

【この記事を読むメリット】

  • Pentagonで利用しているFlutterのPaginationListが使えるようになります

【こんな方に参考にしていただきたい】

  • シンプルなPaginationListを求めている人
目次

【結論】

今回は弊社で利用しているPaginationListについて環境、解説、利用方法の3つにまとめました。

環境

Flutter 2.2.3

利用しているPackage

visibility_detector

弊社で利用しているPaginationListの解説

ItemBuilder

リストで表示する内容を定義するためのエイリアスです。

PaginationListData

ページング機能のデータが付加されたリストデータのクラスです。

リスト、ページ番号、次のコンテンツがあるかないかを保持しています。
保持するリストのクラスはジェネリクスで定義しています。

PaginationListView

ページネーションを行うListViewのクラスです。

先ほど定義したlistData、itemBuilder、
indicator、separator、追加ロードを行う際のコールバック、paddingの定義を行えます。

pagination_list_view.dart

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

/// リスト要素のWidgetのBuilder
typedef ItemBuilder<T> = Widget Function(
  BuildContext context,
  int index,
  T item,
);

/// 次のページの読み込みが要求されたときのコールバック
typedef OnLoadRequested = void Function(int nextPage);

/// ページング機能のデータが付加されたリストデータ
@immutable
class PaginationListData<T> {
  const PaginationListData(this.list, this.currentPage,
      {required this.hasNextPage});

/// リストデータ
  /// (基本的に可変操作を行わないこと。)
final List<T> list;

/// 現在のページ番号
final int currentPage;

/// 次のページのコンテンツが存在するかどうか
final bool hasNextPage;

  @override
  int get hashCode =>
      31 * (31 * list.hashCode + currentPage.hashCode) + hasNextPage.hashCode;

  @override
  bool operator ==(Object other) =>
      other is PaginationListData &&
      list == other.list &&
      currentPage == other.currentPage &&
      hasNextPage == other.hasNextPage;
}

/// ページング機能付きリスト
class PaginationListView<T> extends StatefulWidget {
  const PaginationListView({
    Key? key,
    required this.listData,
    required this.itemBuilder,
    this.indicator =defaultIndicator,
    this.separatorBuilder,
    this.onLoadRequested,
    this.padding = EdgeInsets.zero,
  }) : super(key: key);

/// デフォルトのIndicator
static constdefaultIndicator= Padding(
    padding: EdgeInsets.all(8),
    child: Center(child: CircularProgressIndicator()),
  );

  final PaginationListData<T> listData;
  final ItemBuilder<T> itemBuilder;
  final Widget indicator;
  final IndexedWidgetBuilder? separatorBuilder;
  final OnLoadRequested? onLoadRequested;
  final EdgeInsets padding;

  @override
  State<StatefulWidget> createState() => _PaginationListViewState<T>();
}

class _PaginationListViewState<T> extends State<PaginationListView<T>> {
  //  最後に読み込み要求を掛けたリストデータのハッシュ値
  //  (読み込み要求のコールバックを重複して呼ばないようにするための記録用)
  int? hash;

  @override
  Widget build(BuildContext context) {
    final list = widget.listData.list;
    final shouldShowIndicator = widget.listData.hasNextPage;
    final totalCount = list.length + (shouldShowIndicator ? 1 : 0);

    return ListView.separated(
      padding: widget.padding,
      itemCount: totalCount,
      separatorBuilder: widget.separatorBuilder ?? (context, index) => SizedBox(),
      itemBuilder: (context, index) {
        if (index < list.length) {
          return widget.itemBuilder(context, index, list[index]);
        } else {
          return VisibilityDetector(
            key: UniqueKey(),
            onVisibilityChanged: (info) {
              //  可視部分が閾値(しきいち)を超えた場合、
              //  読み込み要求のコールバックを重複呼び出しがないように呼び出す。
              const threshold = 0.0;
              if (info.visibleFraction > threshold) {
                final currentHash = widget.listData.hashCode;
                final shouldNotify = hash != currentHash;

                if (shouldNotify) {
                  hash = currentHash;

                  final nextPage = widget.listData.currentPage + 1;
                  if (widget.onLoadRequested != null) {
                    widget.onLoadRequested!(nextPage);
                  }
                }
              }
            },
            child: widget.indicator,
          );
        }
      },
    );
  }
}

利用方法

main.dart

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pagination_test/pagination_test_widget.dart';

void main() {
  runApp(
    // スコープの追加
    ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Pagination Test',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: PaginationTestWidget(),
    );
  }
}

pagination_test_widget.dart

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pagination_test/pagination_list_view.dart';
import 'package:pagination_test/pagination_test_widget_model.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Stack(
          children: [
            HookBuilder(
              builder: (context) {
                // リストデータ
                final listData = useProvider(paginationTestWidgetModelProvider
                    .select((value) => value.listData));
                // ページネーションリスト、int型で待ち受ける
                return PaginationListView<int>(
                  listData: listData,
                  // アイテムの生成
                  itemBuilder: (context, idx, value) {
                    return Text(value.toString());
                  },
                  // 次のページの読み込み
                  onLoadRequested: (nextPage) {
                    // ViewModelで処理を行う
                    context
                        .read(paginationTestWidgetModelProvider)
                        .fetchList(nextPage);
                  },
                );
              },
            ),
            Align(
              alignment: Alignment.bottomRight,
              child: MaterialButton(
                child: const Text('-1を最上部に追加'),
                onPressed: () {
                  context
                      .read(paginationTestWidgetModelProvider)
                      .addDataAtTop();
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

pagination_test_widget_model.dart

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pagination_test/pagination_list_view.dart';

final paginationTestWidgetModelProvider =
    ChangeNotifierProvider.autoDispose((_) {
  return PaginationTestWidgetModel();
});

class PaginationTestWidgetModel extends ChangeNotifier {
  PaginationTestWidgetModel() {
    _listData = PaginationListData<int>(List.generate(10, (i) => i), 1,
        hasNextPage: true);
    notifyListeners();
  }

  late PaginationListData<int> _listData;
  PaginationListData<int> get listData => _listData;

  Future<void> fetchList(int page) async {
    _listData = PaginationListData<int>(
        _listData.list + List.generate(10, (i) => i + 10 * (page - 1)), page,
        hasNextPage: true);
    notifyListeners();
  }

  Future<void> addDataAtTop() async {
    _listData = PaginationListData<int>(
        [-1] + _listData.list, _listData.currentPage,
        hasNextPage: true);
    notifyListeners();
  }
}

動作

リストの末尾になると、onLoadRequestedが発火してPaginationTestWidgetModelfetchListを呼び出すことで次のページのデータを作成できます。

また、最上部に-1をタップすることで、最上部に-1を挿入できることがわかります。こちらを応用すると、SNSなどの投稿があるリストで、最新のデータをローカル上で追加することなどに利用できます。
末尾の場合、fetchする際に少し工夫が必要ですが、最上部と同じやりかたで表現することが可能です。

https://i.gyazo.com/e622acdeca44504149de67bfaf0bfc51.gif

【まとめ】

  • Pentagonで利用しているPaginationListの概要と実装方法がわかりました。

シンプルなFlutterのPaginationListを利用したい人の参考になれば幸いです。

採用情報はこちら
目次