1. HOME
  2. ブログ
  3. IT技術
  4. 【Flutter】スティッキーヘッダーとグループ化されたリスト(MaterialとCupertino両対応)
【Flutter】スティッキーヘッダーとグループ化されたリスト(MaterialとCupertino両対応)

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

スティッキーヘッダーのリストを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は以下のようにして進めていきます。

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

https://pub.dev/packages/flutter_platform_widgets

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

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

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

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

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

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

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

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

今回作成するスティッキーヘッダーでは、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を実装していきましょう。

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

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

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

最初の 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を実装していきましょう。

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として設定できるようにしたものを実装します。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Material  -> PlatformApp

Scaffold  -> PlatformScaffold

Text  -> PlatformText

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

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

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

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

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

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

関連記事

採用情報

\ あの有名サービスに参画!? /

バックエンドエンジニア

\ クリエイティブの最前線 /

フロントエンドエンジニア

\ 世界を変える…! /

Androidエンジニア

\ みんなが使うアプリを創る /

iOSエンジニア