4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Flutter] Instagramの TabBar固定 + スクロールを実装する

Last updated at Posted at 2024-04-05

Instagramで使用されているTabBarとは?

Instagramなどで使用されているような、タブバーが上部に固定され、スクロール可能なものは、一般的に "Sticky Tab Bar""Scrollable Tab Bar" と呼ばれます。

このタイプの TabBar は、通常、ページングやスワイプと組み合わせて使用され、ユーザーが異なるコンテンツやセクションにスムーズに移動できるようにします。

文字だけだとイメージが付きにくいかと思いますので、gif画像を用意しました。
"Sticky Tab Bar" を実装した場合、以下のような動きをします。
TabBarView の内容をスクロールした際、 TabBar が上部に固定され、タブバーより上のWidgetを表示する際は、通常通り表示されるというものです。

insta_tabbar.gif

サンプル

今回は上記のような Sticky Tab Bar を実装していきます。
サンプルとしては以下のようなものになります。

画面収録 0006-04-04 22.06.16.gif

サンプルコード①

body Widgt
    body: DefaultTabController(
      length: 4,
      child: NestedScrollView(
        headerSliverBuilder:
            (BuildContext context, bool innerBoxIsScrolled) {
          return [
            const _MyInformation(),
            const _TabBar(),
          ];
        },
        body: TabBarView(
          children: [
            ColorfulGridView(),
            ColorfulGridView(),
            ColorfulGridView(),
            ColorfulGridView(),
          ],
        ),
      ),
    ),

DefaultTabController:

DefaultTabController は、タブのコントローラーになります。 length プロパティには、タブの数を指定します。今回の例では、4つのタブがあるため、lengthは4に設定されています。

NestedScrollView:

NestedScrollView は、ヘッダーとスクロール可能なスクロールビューを作成する際に使われます。headerSliverBuilder プロパティには、タブバーを構築するための関数が指定され、 body プロパティには、タブバーの下に表示される本文の部分が指定されます。

headerSliverBuilder:

headerSliverBuilder は、 タブのスクロールビュー(TabBarView)より上のアイテムを構築するための関数です。今回の例では、アカウントの情報を示す _MyInformation と タブバーを表している _TabBar を設定しています。この関数は、 BuildContextinnerBoxIsScrolled の2つの引数を取ります。 innerBoxIsScrolled は、ネストされたスクロールビュー内のコンテンツがスクロールされているかどうかを示すブール値で、スクロール時にヘッダー部分のUIを動的に変更したい場合に使用します。

TabBarView:

TabBarView は、タブバーで選択されたタブのコンテンツを表示するためのウィジェットです。 children プロパティには、各タブのコンテンツとして表示するウィジェットのリストを指定します。この例では、 ColorfulGridView ウィジェットを4つのタブに設定しているため、4つタブそれぞれに ColorfulGridView が表示されます。

ColorfulGridView:

ColorfulGridView は、今回のサンプル用に作成したカラフルなグリッドビューを表示するためのカスタムウィジェットです。このウィジェットは、各タブのコンテンツとして使用されています。

サンプルコード②

アカウント情報Widget
class _MyInformation extends StatelessWidget {
  const _MyInformation();

  @override
  Widget build(BuildContext context) {
    return SliverList(
      delegate: SliverChildListDelegate(
        [
          Padding(...),
        ],
      ),
    );
  }
}

SliverList:

SliverList は、 ScrollView の一種で、スクロール可能なリストを作成するためのウィジェットです。リスト内の要素が変わらない場合に使用します。

スクロール可能なリストの内容を決定するために、 delegate プロパティには SliverChildDelegateのインスタンスを指定する必要があります。

NestedScrollViewheaderSliverBuilder の中で使用されることが多く、ヘッダー部分にスクロール可能なコンテンツを配置するために使用されます。

delegate:

delegate プロパティには、 SliverList の子ウィジェットを指定するためのデリゲートを設定します。SliverChildDelegate のインスタンスが指定する必要があります。これにより、リスト内の子要素がどのように構築されるかが定義されます。今回の例では、 SliverChildListDelegate を使用しています。

デリゲートとは、特定のタスクや処理を別のオブジェクトに受け渡すためのオブジェクトのことです。Flutterの場合、UIの構築や更新の際にデリゲートが多用されています。例えば、 ListView などのスクロールビューでは、子Widgetをビルドするタイミングやスクロール位置の計算などの処理をデリゲートに受け渡しています。

デリゲートを使うことで、メインロジックから特定の処理を分離でき、コードの保守性と柔軟性を高めることができます。

デリゲートについてもっと詳しく知りたい方はこちらをご覧ください。

SliverChildListDelegate:

SliverChildListDelegate は、リストに表示する子ウィジェットのリストを指定するためのデリゲートクラスです。

SliverChildListDelegate のコンストラクタには、ウィジェットのリストが渡されます。

サンプルコード③

class _TabBar extends StatelessWidget {
  const _TabBar();

  @override
  Widget build(BuildContext context) {
    return const SliverPersistentHeader(
      pinned: true,
      delegate: _StickyTabBarDelegate(
        tabBar: TabBar(...),
      ),
    );
  }
}

SliverPersistentHeader:

SliverPersistentHeader は、スクロール可能なヘッダーを指定するためのウィジェットです。このウィジェットを実装することで、固定された位置にあるヘッダーを作成することができます。

通常、 NestedScrollViewheaderSliverBuilder 内で使用されます。

pinned:

pinned プロパティは、ヘッダーがスクロール中に固定されるかどうかを指定します。このプロパティが true に設定されている場合、ヘッダーはスクロール中に画面上部に固定されます。

delegate:

delegate プロパティには、ヘッダーのレイアウトと動作を制御するためのデリゲートが指定されます。この例では、カスタムデリゲートとして、 _StickyTabBarDelegate が使用されています。

_StickyTabBarDelegate:

_StickyTabBarDelegate は、タブバーをヘッダーに配置するためのカスタムデリゲートです。この例では、タブバーは TabBar ウィジェットで設定しています。

サンプルコード④

class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
  const _StickyTabBarDelegate({required this.tabBar});

  final TabBar tabBar;

  @override
  double get minExtent => tabBar.preferredSize.height;

  @override
  double get maxExtent => tabBar.preferredSize.height;

  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    return Container(
      color: Colors.white,
      child: tabBar,
    );
  }

  @override
  bool shouldRebuild(_StickyTabBarDelegate oldDelegate) {
    return tabBar != oldDelegate.tabBar;
  }
}

_StickyTabBarDelegate は、ピン留め可能なヘッダーに TabBar ウィジェットを配置するためのデリゲートクラスです。ヘッダーの最小・最大の高さは TabBarpreferredSize.heightで決まり、ヘッダーには白い背景に TabBar ウィジェットが表示され、 TabBar ウィジェットが変更された場合のみ再ビルドが行われます。

_StickyTabBarDelegateクラス:

_StickyTabBarDelegate クラスは、 SliverPersistentHeaderDelegate を継承しています。これにより、ヘッダーのデリゲートをカスタマイズすることが可能になります。

コンストラクタでは、 required キーワードを使用して tabBar パラメータを必須にしており、 tabBar はウィジェットツリーにレンダリングするための TabBarウィジェットになります。

minExtent:

このゲッターは、ヘッダーの最小の高さを指定します。

SliverPersistentHeaderDelegate から継承された minExtentゲッターをオーバーライドしており、今回の場合、 TabBarpreferredSize.height (タブバーの高さ) が最小の高さとなります。

maxExtent:

このゲッターは、ヘッダーの最大の高さを指定します。

SliverPersistentHeaderDelegate から継承された maxExtent ゲッターをオーバーライドしており、この場合も、 TabBarpreferredSize.height (タブバーの高さ) が最大の高さとなります。

build:

このメソッドは、ヘッダーのレンダリングを行います。

SliverPersistentHeaderDelegate から継承された build メソッドをオーバーライドしており、今回の実装では、 Container ウィジェットの child として tabBar を渡しています。つまり、白い背景に TabBar ウィジェットを表示する設定になっています。

shouldRebuild:

このメソッドは、デリゲートが再ビルドする必要があるかどうかを判断します。

SliverPersistentHeaderDelegate から継承された shouldRebuild メソッドをオーバーライドしており、この実装では、 tabBar が変更された場合にのみ再ビルドが必要であると判断しています。

全体のコード

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

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(title: const Text('Demo Sticky Tab Bar')),
        body: DefaultTabController(
          length: 4,
          child: NestedScrollView(
            headerSliverBuilder:
                (BuildContext context, bool innerBoxIsScrolled) {
              return [
                const _MyInformation(),
                const _TabBar(),
              ];
            },
            body: TabBarView(
              children: [
                ColorfulGridView(),
                ColorfulGridView(),
                ColorfulGridView(),
                ColorfulGridView(),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _MyInformation extends StatelessWidget {
  const _MyInformation();

  @override
  Widget build(BuildContext context) {
    return SliverList(
      delegate: SliverChildListDelegate(
        [
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              children: [
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Column(
                      children: [
                        Center(
                          child: Container(
                            width: 64,
                            height: 64,
                            decoration: const ShapeDecoration(
                              color: Colors.white,
                              shape: OvalBorder(
                                side: BorderSide(width: 1),
                              ),
                            ),
                          ),
                        ),
                        const SizedBox(height: 16),
                        Text(
                          'sticky tab bar',
                          textAlign: TextAlign.center,
                          style: Theme.of(context).textTheme.titleLarge,
                        ),
                      ],
                    ),
                    const SizedBox(width: 48),
                    const Column(
                      children: [
                        SizedBox(height: 16),
                        Text('7998'),
                        SizedBox(height: 8),
                        Text('投稿'),
                      ],
                    ),
                    const SizedBox(width: 16),
                    const Column(
                      children: [
                        SizedBox(height: 16),
                        Text('9374'),
                        SizedBox(height: 8),
                        Text('フォロワー'),
                      ],
                    ),
                    const SizedBox(width: 16),
                    const Column(
                      children: [
                        SizedBox(height: 16),
                        Text('139'),
                        SizedBox(height: 8),
                        Text('フォロー'),
                      ],
                    ),
                  ],
                ),
                Row(
                  children: [
                    Expanded(
                      child: ElevatedButton(
                          onPressed: null,
                          style: ElevatedButton.styleFrom(
                              backgroundColor: Colors.blue),
                          child: const Text('フォロー')),
                    ),
                    const SizedBox(width: 8),
                    Expanded(
                      child: ElevatedButton(
                          onPressed: null,
                          style: ElevatedButton.styleFrom(
                              backgroundColor: Colors.black),
                          child: const Text('メッセージ')),
                    ),
                  ],
                )
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class ColorfulGridView extends StatelessWidget {
  final Random _random = Random();

  ColorfulGridView({super.key});

  Color _getRandomColor() {
    return Color.fromARGB(
      255,
      _random.nextInt(256),
      _random.nextInt(256),
      _random.nextInt(256),
    );
  }

  @override
  Widget build(BuildContext context) {
    return GridView.count(
      crossAxisCount: 4,
      children: List.generate(
        50,
        (index) => Container(
          color: _getRandomColor(),
          child: Center(
            child: Text(
              'Item $index',
              style: const TextStyle(
                color: Colors.white,
                fontSize: 20.0,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _TabBar extends StatelessWidget {
  const _TabBar();

  @override
  Widget build(BuildContext context) {
    return const SliverPersistentHeader(
      pinned: true,
      delegate: _StickyTabBarDelegate(
        tabBar: TabBar(
          tabs: [
            Tab(
              child: Icon(
                Icons.grid_on_sharp,
                color: Colors.black,
              ),
            ),
            Tab(
              child: Icon(
                Icons.add_a_photo,
                color: Colors.black,
              ),
            ),
            Tab(
              child: Icon(
                Icons.add_card,
                color: Colors.black,
              ),
            ),
            Tab(
              child: Icon(
                Icons.supervisor_account_rounded,
                color: Colors.black,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
  const _StickyTabBarDelegate({required this.tabBar});

  final TabBar tabBar;

  @override
  double get minExtent => tabBar.preferredSize.height;

  @override
  double get maxExtent => tabBar.preferredSize.height;

  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    return Container(
      color: Colors.white,
      child: tabBar,
    );
  }

  @override
  bool shouldRebuild(_StickyTabBarDelegate oldDelegate) {
    return tabBar != oldDelegate.tabBar;
  }
}

参考資料

告知

最後にお知らせとなりますが、イーディーエーでは一緒に働くエンジニアを
募集しております。詳しくは採用情報ページをご確認ください。

みなさまからのご応募をお待ちしております。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?