• トップ
  • ブログ一覧
  • 【Flutter】スティッキーヘッダーとグループ化されたリスト(MaterialとCupertino両対応)
  • 【Flutter】スティッキーヘッダーとグループ化されたリスト(MaterialとCupertino両対応)

    りゅうちゃん(エンジニア)りゅうちゃん(エンジニア)
    2021.11.11

    IT技術

    スティッキーヘッダーのリストをFlutterで作る!

    今回はiOSでよく見る、以下のようなスティッキーヘッダーのリストをFlutterで作っていきたいと思います。

    FlutterではSliverPersistentHeaderのpinned を使うことで同じような機能が実装できますが、こちらでは全てのヘッダーがスタックされるようになってしまいます。

    他の手段としてプラグインではありますが、flutter_sticky_headerを利用することでスティッキーヘッダーの実装はできます。

    しかしヘッダー同士で接触した時の非表示処理がフェードアウトとなっているのと、Cupertino(iOS)デザインに対応していません。

    https://pub.dev/packages/flutter_sticky_header

    また実際にこのようなリストを利用する際は、APIから受け取ったModelクラスを使うことが多いと思うので、grouped_listプラグインのようにグループ化しやすいよう実装していきたいと思います。

    https://pub.dev/packages/grouped_list

    スティッキーヘッダーを実装する

    早速実装に入っていきます!

    pubspecは以下のようにして進めていきます。

    1environment:
    2  sdk: ">=2.12.0 <3.0.0"
    3
    4dependencies:
    5  flutter:
    6    sdk: flutter
    7
    8  provider: '>=5.0.0-nullsafety.5 <6.0.0'
    9  flutter_platform_widgets:

    flutter_platform_widgetsは、端末ごとにMaterialとCupertinoのデザインを自動的に表示してくれるプラグインです。

    https://pub.dev/packages/flutter_platform_widgets

    今回実装するクラスの呼び出しイメージ

    1class _ListData {
    2  final int id;
    3  final String title;
    4
    5  _ListData({required this.id, required this.title});
    6}
    7
    8final list = List.generate(100, (index) => _ListData(id: index % 10, title: 'Title ${index.toString()}'));
    9
    10final listWidget = StickyGroupList(
    11  elements: list,
    12  groupBy: (_ListData element) => element.id,
    13  groupHeaderTitleBuilder: (int groupByValue) => PlatformText(groupByValue.toString()),
    14  itemBuilder: (context, _ListData element) => Container(
    15    margin: EdgeInsets.all(4),
    16    padding: EdgeInsets.all(12),
    17    child: PlatformText(element.title, style: TextStyle(color: Colors.black, fontSize: 18)),
    18  ),
    19  onRefresh: () async {
    20    // 更新処理をここに書く
    21  },
    22  groupHeaderBackgroundColor: Colors.blue,
    23);

    ほぼgrouped_listと同じ感覚で使えるようにしています。

    スティッキーヘッダーとグループ化されたリストの実装

    まずは変数部分とイニシャライザ部分を作成しましょう。

    1class StickyGroupList<T, E extends Object> extends StatelessWidget {
    2  // リスト化したいModelを入れる
    3  final List<T> elements;
    4
    5  // グループ化するための値を返す
    6  final E Function(T element) groupBy;
    7
    8  // グループごとのHeaderのWidgetの中身を設定できる
    9  final Widget Function(E value) groupHeaderTitleBuilder;
    10
    11  // リスト部分のWidgetを設定できる
    12  final Widget Function(BuildContext context, T element) itemBuilder;
    13
    14  // リストを下に引っ張った時の更新処理を設定できる
    15  final Future<void> Function()? onRefresh;
    16
    17  // ヘッダーの背景色
    18  final Color? groupHeaderBackgroundColor;
    19
    20  // ヘッダーの高さ
    21  final double groupHeaderHeight;
    22
    23  StickyGroupList({
    24    Key? key,
    25    required this.elements,
    26    required this.groupBy,
    27    required this.groupHeaderTitleBuilder,
    28    required this.itemBuilder,
    29    this.onRefresh,
    30    this.groupHeaderBackgroundColor,
    31    this.groupHeaderHeight = 60,
    32  }) : super(key: key);

    任意のModelクラスをグループ化をするため、ジェネリクスを利用しています。

    Tがグループ化したいModelクラス、EがModel内で一意な値になるIDの型(大抵はintかString)になります。

    grouped_listとだいたい同じ感じのイニシャライザになりましたね。

    次にbuild 周りを作成していきましょう。

    1  final Map<E, GlobalObjectKey> _headerGlobalKeys = {};
    2  final _scrollController = ScrollController();
    3
    4  @override
    5  Widget build(BuildContext context) {
    6    var itemGroups = _itemGroups;
    7    itemGroups.keys
    8        .forEach((key) => _headerGlobalKeys[key] = GlobalObjectKey(key));
    9    return ListenableProvider(
    10      create: (_) {
    11        final viewModel = StickyGroupListViewModel(headerDefaultHeight: groupHeaderHeight, keys: _headerGlobalKeys.values.toList());
    12        _scrollController.addListener(() => _scrollListener(viewModel));
    13        return viewModel;
    14      },
    15      dispose: _dispose,
    16      child: _SliverGroupList(
    17        groupHeaderTitleBuilder: groupHeaderTitleBuilder,
    18        itemBuilder: itemBuilder,
    19        onRefresh: onRefresh,
    20        groupHeaderBackgroundColor: groupHeaderBackgroundColor,
    21        itemGroups: itemGroups,
    22        headerGlobalKeys: _headerGlobalKeys,
    23        scrollController: _scrollController,
    24      ),
    25    );
    26  }
    27
    28  Map<E, List<T>> get _itemGroups =>
    29      elements.fold(Map<E, List<T>>(), (itemGroup, element) {
    30        final group = groupBy(element);
    31        var groupItem = itemGroup[group];
    32        if (groupItem != null) {
    33          groupItem.add(element);
    34          itemGroup[group] = groupItem;
    35        } else {
    36          itemGroup[group] = [element];
    37        }
    38        return itemGroup;
    39      });
    40
    41  void _dispose(BuildContext context, ChangeNotifier? notifier) {
    42    notifier?.dispose();
    43    _scrollController.dispose();
    44  }
    45
    46  void _scrollListener(StickyGroupListViewModel viewModel) {
    47    // ヘッダーの接触判定を行う(後述)
    48  }

    今回作成するスティッキーヘッダーでは、ScrollControllerを使ってヘッダー同士の接触を感知し、ヘッダー自身の高さと表示非表示を調整するように実装していきます。

    build の中身を見ていきましょう。

    itemGroups ではイニシャライザで受け取ったelement とgroupBy を使い、グループ化を行っていきます。

    次のitemGroups.keys.forEach では、さきほど取得したitemGroups のkeyごとにGlobalObjectKeyを生成し、_headerGlobalKeys にまとめていますね。

    GlobalObjectKeyはヘッダーのスクロール位置を取得するのと、ヘッダーごとの高さを管理する為に利用します。

    return ではListenableProviderを返しています。

    Providerを使う場面ではほとんどChangeNotifierProviderだと思いますが、_scrollController.dispose() を行うために、Providerのdispose処理を設定できるこちらを使用しています。

    create: ではViewModelの生成と_scrollController.addListener() などの初期化処理を行っていますね。

    最後のchild: では、リストの本体となる_SliverGroupListを入れています。

    これによって、ViewModelでヘッダーごとの設定値を管理し、変更されるごとに_SliverGroupListを再ビルドする形になります。

    次にリストの本体となる_SliverGroupListを実装していきましょう。

    まずは変数とイニシャライザからです。

    1class _SliverGroupList<T, E extends Object> extends StatelessWidget {
    2  final Widget Function(E value) groupHeaderTitleBuilder;
    3  final Widget Function(BuildContext context, T element) itemBuilder;
    4  final Future<void> Function()? onRefresh;
    5  final Color? groupHeaderBackgroundColor;
    6  final Map<E, List<T>> itemGroups;
    7  final Map<E, GlobalObjectKey> headerGlobalKeys;
    8  final ScrollController? scrollController;
    9
    10  _SliverGroupList(
    11      {required this.groupHeaderTitleBuilder,
    12      required this.itemBuilder,
    13      required this.onRefresh,
    14      required this.groupHeaderBackgroundColor,
    15      required this.itemGroups,
    16      required this.headerGlobalKeys,
    17      required this.scrollController});
    18}

    ほとんどがStickyGroupListがイニシャライザで受けとったやつですね。

    次にbuild 周りを書いていきましょう。

    1  @override
    2  Widget build(BuildContext context) {
    3    final viewModel = context.watch<StickyGroupListViewModel>();
    4    return sliverGroupListWidget(viewModel);
    5  }
    6
    7  Widget sliverGroupListWidget(StickyGroupListViewModel viewModel) {
    8    List<Widget> slivers = [];
    9    itemGroups.forEach((key, value) {
    10      final globalKey = headerGlobalKeys[key];
    11      slivers
    12        ..add(DefaultSliverPersistentHeader(
    13          headerKey: globalKey,
    14          title: groupHeaderTitleBuilder(key),
    15          backgroundColor: groupHeaderBackgroundColor,
    16          pinned: viewModel.pinnedMap[globalKey],
    17          height: viewModel.groupHeaderHeightMap[globalKey],
    18        ))
    19        ..add(SliverList(
    20            delegate: SliverChildBuilderDelegate(
    21                (context, index) => itemBuilder(context, value[index]),
    22                childCount: value.length)));
    23    });
    24
    25    final onRefresh = this.onRefresh;
    26    if (onRefresh != null) {
    27      return PlatformRefreshIndicator(
    28          onRefresh: onRefresh, slivers: slivers, controller: scrollController);
    29    } else {
    30      return CustomScrollView(
    31        slivers: slivers,
    32        controller: scrollController,
    33      );
    34    }
    35  }

    最初のfinal viewModel = context.watch<StickyGroupListViewModel>() によって、ViewModelからの変更通知で再buildを行なってくれるようになります。

    Widget sliverGroupListWidget(StickyGroupListViewModel viewModel) を見ていきましょう。

    まずはリストの中身であるslivers の生成を行なっていきます。

    今回リスト表示に利用するCustomScrollViewでは、要素ごとにヘッダーやリストを配列として持つ必要があります。

    なので受け取ったModelをグループ化したitemGroup からkey(グループID)とvalue(Model本体)をforEachで取得し、ヘッダーとヘッダーに紐づくリストをそれぞれ生成していますね。

    またここで、さきほど生成したGlobalObjectKeyをヘッダーに渡しています。

    渡した先でヘッダーとなるWidgetにGlobalObjectKeyを設定してあげることで、StickyGroupList上でヘッダーごとにスクロール位置を取得することができるようになります。

    次のonRefresh でのif文では、リストを下に引っ張った時の更新処理が設定されるかどうかで分岐しています。

    CustomScrollViewで上記処理を実装したい場合、MaterialではRefreshIndicatorを利用する必要があります。

    PlatformRefreshIndicatorとDefaultSliverPersistentHeaderは自作したクラスなので、次はこちらを実装していきましょう。

    Material(Android)とCupertino(iOS)デザインへの対応

    今回作成するアプリは2つのデザインに対応していますが、この対応のほとんどがpodspecで記載したflutter_platform_widgetsを使えば解決します。

    しかし一部対応していないwidgetがあるので、それに関しては自作する必要があります。

    今回はRefreshIndicatorを各デザインに対応させるため、PlatformRefreshIndicatorを実装していきましょう。

    1import 'package:flutter/cupertino.dart';
    2import 'package:flutter/material.dart';
    3import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
    4
    5class PlatformRefreshIndicator extends PlatformWidget {
    6  final Key? key;
    7  final Future<void> Function() onRefresh;
    8  final List<Widget> slivers;
    9  final ScrollController? controller;
    10
    11  PlatformRefreshIndicator(
    12      {this.key,
    13      required this.onRefresh,
    14      required this.slivers,
    15      this.controller});
    16
    17  @override
    18  Widget build(BuildContext context) {
    19    return PlatformWidget(
    20      material: (_, __) => RefreshIndicator(
    21          child: CustomScrollView(
    22            key: key,
    23            controller: controller,
    24            slivers: slivers,
    25          ),
    26          onRefresh: onRefresh),
    27      cupertino: (_, __) => CustomScrollView(
    28        key: key,
    29        controller: controller,
    30        slivers: <Widget>[
    31              CupertinoSliverRefreshControl(
    32                refreshTriggerPullDistance: 100.0,
    33                refreshIndicatorExtent: 60.0,
    34                onRefresh: onRefresh,
    35              ),
    36            ] +
    37            slivers,
    38      ),
    39    );
    40  }
    41}

    build ではflutter_platform_widgetsのPlatformWidgetを返していますね。

    これによってAndroidの場合はmaterial: のwidgetが、iOSの場合はcupertino: のwidgetが表示されるようになります。

    少々面倒な感じになっていますが、MaterialとCupertinoでリストを下に引っ張った時の実装方法が少し異なるため、このように分ける必要があります。

    MaterialではRefreshIndicatorのchild: にCustomScrollViewを、CupertinoではCupertinoSliverRefreshControlをslivers: 配列の一番上に配置しましょう。

    これで更新処理時のインジケータがそれぞれのデザインで表示されるようになりました。

    SliverPersistentHeaderの実装

    CustomScrollViewでスティッキーヘッダーを表示したい場合、冒頭にもあったSliverPersistentHeaderを利用するのがおそらく一番の近道です。

    イニシャライザにあるpinned: にtrueを設定することで、設定したヘッダーに紐づくリストがスクロールしきった後でも、画面上部に残り続けるようになります。

    この特性を利用して、全てのヘッダーのpinned: をtrueにした上で、それぞれのヘッダーの高さを変えていくように実装していきましょう。

    また、ヘッダーのデザインは自由にカスタムはできるのですが、今回はtitle部分のみをwidgetとして設定できるようにしたものを実装します。

    1import 'package:flutter/cupertino.dart';
    2import 'package:flutter/material.dart';
    3import 'package:flutter/rendering.dart';
    4
    5class DefaultSliverPersistentHeader extends StatelessWidget {
    6  final Key? headerKey;
    7  final Widget? title;
    8  final Color? backgroundColor;
    9  final double maxHeight;
    10  final double minHeight;
    11
    12  DefaultSliverPersistentHeader(
    13      {this.headerKey,
    14      this.title,
    15      this.backgroundColor,
    16      required this.maxHeight,
    17      required this.minHeight});
    18
    19  @override
    20  Widget build(BuildContext context) {
    21    return SliverPersistentHeader(
    22        key: headerKey,
    23        delegate: _DefaultSliverPersistentHeaderDelegate(
    24            child: Container(
    25                color: backgroundColor,
    26                child: Padding(
    27                  child: Align(
    28                    alignment: Alignment.centerLeft,
    29                    child: title,
    30                  ),
    31                  padding: EdgeInsets.symmetric(horizontal: 8),
    32                )),
    33            maxHeight: maxHeight,
    34            minHeight: minHeight),
    35        pinned: true);
    36  }
    37}

    build から見ていきましょう。

    key: には受け取ったGlobalObjectKeyを入れることで、親WidgetでSliverPersistentHeaderのスクロール位置の情報などが取得可能になります。

    delegate: ではSliverPersistentHeaderの中身を定義していきます。

    ただしそれにはdelegateクラスを経由する必要があるので、次は_DefaultSliverPersistentHeaderDelegateを実装しましょう。

    1class _DefaultSliverPersistentHeaderDelegate
    2    extends SliverPersistentHeaderDelegate {
    3  final Widget child;
    4  final double maxHeight;
    5  final double minHeight;
    6
    7  _DefaultSliverPersistentHeaderDelegate({required this.child, required this.maxHeight, required this.minHeight});
    8
    9  @override
    10  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child;
    11
    12  @override
    13  double get maxExtent => maxHeight;
    14
    15  @override
    16  double get minExtent => minHeight;
    17
    18  @override
    19  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => minExtent != oldDelegate.minExtent || maxExtent != oldDelegate.maxExtent
    20}

    SliverPersistentHeaderのdelegateなので、そのままSliverPersistentHeaderDelegateを継承しています。

    build にはStatelessWidgetなどと同じように、表示するWidgetを設定しましょう。

    maxExtentminExtent はスクロール時のヘッダーの高さの最大値と最低値を設定できます。

    minExtent に_SliverGroupListから受け取った縮小した状態のヘッダーの高さを設定することで、スティッキーヘッダーのような動作を再現することができます。

    maxExtent にはヘッダーのデフォルトの高さを渡しましょう。

    一見すると、minExtent に0を設定すればスティッキーヘッダーになりそうですが、ヘッダーが接触する前にヘッダーの高さが0になってしまうので、こちら側で管理して使ってあげる必要があります。

    shouldRebuild では、今回の実装だとmaxExtent とminExtent が可変なので、それらの値が前と異なる場合は再buildされるようにtrueを返しています。

    これでWidget関連の実装はできたので、今度はヘッダーの高さを管理するStickyGroupListViewModelを実装していきましょう。

    ヘッダーごとの高さを管理する

    1import 'package:flutter/cupertino.dart';
    2import 'package:flutter/material.dart';
    3
    4class StickyGroupListViewModel extends ChangeNotifier {
    5  final double headerDefaultHeight;
    6
    7  final List<GlobalObjectKey> keys;
    8
    9  Map<GlobalObjectKey, double> _headerHeightMap = {};
    10
    11  Map<GlobalObjectKey, double> get headerHeightMap => _headerHeightMap;
    12
    13  StickyGroupListViewModel({required this.headerDefaultHeight, required this.keys}) {
    14    _headerHeightMap = keys.fold(Map<GlobalObjectKey, double>(), (previousValue, element) {
    15      previousValue[element] = headerDefaultHeight;
    16      return previousValue;
    17    });
    18  }
    19
    20  // 指定したkeyのヘッダーの高さを取得する
    21  double getHeaderHeight(GlobalObjectKey? key) {
    22    return _headerHeightMap[key] ?? 0;
    23  }
    24
    25  // 指定したkeyのヘッダーの高さを更新する
    26  void setHeaderHeight(GlobalObjectKey key, double height) {
    27    if (height > headerDefaultHeight) {
    28      if (getHeaderHeight(key) == headerDefaultHeight) {
    29        return;
    30      }
    31      _headerHeightMap.update(key, (value) => headerDefaultHeight);
    32    } else if (height < 0) {
    33      if (getHeaderHeight(key) == 0) {
    34        return;
    35      }
    36      _headerHeightMap.update(key, (value) => 0);
    37    } else {
    38      if (getHeaderHeight(key) == height) {
    39        return;
    40      }
    41      _headerHeightMap.update(key, (value) => height);
    42    }
    43    notifyListeners();
    44  }
    45}

    ViewModelではChangeNotifierを継承し、notifyListeners() で値の変更をWidgetに通知しましょう。

    メンバ変数の_headerHeightMap はGlobalObjectKeyをkeyとして、ヘッダーごとの高さの値が保持されています。

    setHeaderHeight では、ヘッダーの高さの範囲である0〜デフォルト値に納まるように制御を入れていますね。

    これでヘッダーを更新するための土台ができたので、最後にスクロール時にヘッダー同士が接触した場合の挙動を実装していきましょう。

    ヘッダー同士の接触を検知し、ヘッダーの高さを操作する

    StickyGroupListで未実装だった_scrollListenerの中身を実装していきます。

    1void _scrollListener(StickyGroupListViewModel viewModel) {
    2  final keyList = _headerGlobalKeys.values.toList();
    3  final currentKey = keyList.firstWhere((element) => viewModel.getHeaderHeight(element) > 0);
    4  final currentHeader = currentKey.currentContext?.findRenderObject() as RenderSliverPersistentHeader?;
    5
    6  if (currentHeader == null) {
    7    return;
    8  }
    9
    10  final isDownScroll = currentHeader.constraints.userScrollDirection == ScrollDirection.reverse;
    11  if (isDownScroll && keyList.length > keyList.indexOf(currentKey) + 1) {
    12    // 下スクロール時
    13  } else if (!isDownScroll) {
    14    // 上スクロール時
    15  }
    16}

    まず最初に、表示されている中で一番上のヘッダーであるcurrentHeader を取得します。

    表示されている中ということなので、_headerGlobalKeys を最初から順番に見て高さが0以上のものをcurrentHeader として取得していますね。

    次のfinal isDownScroll = currentHeader.constraints.userScrollDirection == ScrollDirection.reverse では、上のスクロールか下のスクロールかを取得しています。

    こちらはScrollDirection.reverse で下スクロール、ScrollDirection.forward で上スクロールと判定することができます。

    それではまず下スクロールであった場合の処理を書いていきましょう。

    1// 下スクロール時
    2
    3// ヘッダー同士が接触した後の高さ更新
    4if (keyList.length > keyList.indexOf(currentKey) + 1) {
    5  final nextKey = keyList[keyList.indexOf(currentKey) + 1];
    6  final nextHeader = nextKey.currentContext?.findRenderObject() as RenderSliverPersistentHeader?;
    7
    8  if (nextHeader != null) {
    9    final double currentHeight = nextHeader.constraints.precedingScrollExtent - _scrollController.offset;
    10
    11    // 高さ更新
    12    viewModel.setHeaderHeight(currentKey, currentHeight);
    13  }
    14}

    下スクロールでは上部のヘッダーと、その次のヘッダーを見て上部のヘッダーの高さを調整していきます。

    currentHeader を取得した時と同じ要領で1つ下のヘッダーであるnextHeader を取得し、nextHeader.constraints.precedingScrollExtent - _scrollController.offset で、下部のヘッダーが上部のヘッダーに食い込んだ状態での、上部ヘッダーのあるべきサイズであるcurrentHeight を計算しています。

    nextHeader.constraints.precedingScrollExtent は、nextHeader の座標がスクロールの中でどの位置にあるかという値になっています。

    そこからスクロール位置である_scrollController.offset をマイナスすることで、画面上の最上部からnextHeader までの間がどれだけ空いているかを取得しているという感じです。

    そうしたらcurrentHeight をViewModelに渡しましょう。

    これで下スクロール時に上部のヘッダーの高さが計算され、ViewModelを通じてヘッダーの高さが変更されるようになりました。

    次に上スクロール時の処理を実装していきましょう。

    1// 上スクロール時
    2
    3// ヘッダー同士が接触した後の高さ更新
    4if (viewModel.getHeaderHeight(currentKey) < groupHeaderHeight) {
    5  // 上部ヘッダーの高さを変更
    6
    7  if (keyList.length > keyList.indexOf(currentKey) + 1) {
    8    final nextKey = keyList[keyList.indexOf(currentKey) + 1];
    9    final nextHeader = nextKey.currentContext?.findRenderObject() as RenderSliverPersistentHeader?;
    10    if (nextHeader != null) {
    11      final currentHeight = nextHeader.constraints.precedingScrollExtent - _scrollController.offset;
    12
    13      // 高さ更新
    14      viewModel.setHeaderHeight(currentKey, currentHeight);
    15    }
    16  }
    17} else if (0 <= keyList.indexOf(currentKey) - 1) {
    18  // 上部ヘッダーより前にあるヘッダーを表示
    19  final previousKey = keyList[keyList.indexOf(currentKey) - 1];
    20  final previousHeight = currentHeader.constraints.precedingScrollExtent - _scrollController.offset;
    21
    22  // 高さ更新
    23  viewModel.setHeaderHeight(previousKey, previousHeight);
    24}

    こちらは下スクロールと異なり、処理が2つに分けれています。

    上側は下スクロールと同じく、ヘッダー同士が接触した時の高さを計算しており、計算方法なども同じですね。

    下側は最上部にヘッダーがある状態で、上スクロールした時にさらにその上にあるヘッダーを表示するかどうかを計算しています。

    ただ計算方法などはほとんど同じで、nextHeader ではなく、最上部に表示されているcurrentHeader を使っているだけですね。

    分岐条件としても、上側は最上部のヘッダーの高さが最大値になっていない場合で、下側は最上部のヘッダーが最大値の時に一つ前のヘッダーが存在するかを見ているだけです。

    ヘッダーの高さが最小値である0になっていないかという条件も必要ではありますが、currentHeader 取得時点で高さが0より上になるようになっているので省いています。

    これで上下のスクロールで上部ヘッダーの高さの変更をViewModelから、DefaultSliverPersistentHeaderまで通知されるようになりました

    スティッキーヘッダーを使ってみる

    スティッキーヘッダーを表示できるクラスが実装できたので、実際にアプリ上で表示させてみましょう!

    1import 'package:flutter/cupertino.dart';
    2import 'package:flutter/material.dart';
    3import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
    4import 'platform/platform_theme.dart';
    5import 'sticky_group_list.dart';
    6
    7void main() {
    8  runApp(MyApp());
    9}
    10
    11class MyApp extends StatelessWidget {
    12  // This widget is the root of your application.
    13  @override
    14  Widget build(BuildContext context) {
    15    return PlatformApp(
    16      title: 'Sliver Group List',
    17      material: (context, platform) => MaterialAppData(theme: ThemeData(primarySwatch: Colors.blue)),
    18      cupertino: (context, platform) => CupertinoAppData(theme: CupertinoThemeData(primaryColor: CupertinoColors.systemBlue)),
    19      home: _HomeView(),
    20    );
    21  }
    22}
    23
    24class _HomeView extends StatelessWidget {
    25  @override
    26  Widget build(BuildContext context) {
    27    final list = List.generate(100, (index) => _ListData(id: index, title: 'Title ${index.toString()}'));
    28
    29    return PlatformScaffold(
    30      appBar: PlatformAppBar(
    31        title: PlatformText('サンプル'),
    32      ),
    33      body: StickyGroupList(
    34        elements: list,
    35        groupBy: (_ListData element) => element.id.toString(),
    36        groupSeparatorTitleBuilder: (String groupByValue) => PlatformText(groupByValue),
    37        itemBuilder: (context, _ListData element) => Container(
    38          margin: EdgeInsets.all(4),
    39          padding: EdgeInsets.all(12),
    40          decoration: BoxDecoration(
    41            border: Border.all(color: Colors.grey),
    42            borderRadius: BorderRadius.circular(4),
    43          ),
    44          child: PlatformText(element.title, style: TextStyle(color: Colors.white, fontSize: 18)),
    45        ),
    46        onRefresh: () async {
    47          print("refresh");
    48        },
    49        groupSeparatorBackgroundColor: PlatformTheme.of(context).primaryColor,
    50      ),
    51      iosContentPadding: true,
    52      iosContentBottomPadding: true,
    53    );
    54  }
    55}
    56
    57class _ListData {
    58  final int id;
    59  final String title;
    60
    61  _ListData({required this.id, required this.title});
    62}

    いくつかflutter_platform_widgetsのクラスを利用しています。

    Material  -> PlatformApp

    Scaffold  -> PlatformScaffold

    Text  -> PlatformText

    Theme のprimaryColor については、flutter_platform_widgetsには実装されていなかったので以下のように自作しました。

    1import 'dart:io';
    2
    3import 'package:flutter/cupertino.dart';
    4import 'package:flutter/material.dart';
    5
    6class PlatformTheme {
    7  final BuildContext context;
    8
    9  PlatformTheme({required this.context});
    10
    11  factory PlatformTheme.of(BuildContext context) => PlatformTheme(context: context);
    12
    13  Color get primaryColor {
    14    if (Platform.isIOS) {
    15      return CupertinoTheme.of(context).primaryColor;
    16    } else {
    17      return Theme.of(context).primaryColor;
    18    }
    19  }
    20}

    実行されている端末がiOSかどうかを見て、そうだった場合はCupertinoTheme を、そうじゃなかった場合はThemeprimaryColor を返しているだけですね。

    iOSとAndroidそれぞれで以下のようになります!

    以上がスティッキーヘッダーとグループ化されたリスト(MaterialとCupertino両対応)の実装でした。

    作成したアプリは以下にあるので、こちらも参考にしてみてください。

    https://github.com/ryuto-imai/flutter_sticky_group_list

    りゅうちゃん(エンジニア)

    りゅうちゃん(エンジニア)

    おすすめ記事