【Flutter】ExtendedNestedScrollViewを使用してタブ画面の実装をしてみた

株式会社Neverの近藤です。

株式会社Neverはモバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽に[こちら](https://neverjp.com/contact/
)へお問合せください。

今日は FlutterでExtendedNestedScrollViewを用いて、Twitterのようなプロフィール画面とタブ画面の実装 について解説します。

# 目次
[1.成果物](#1-成果物)
[2.概要](#2-概要)
[3.実装手順](#3-実装手順)
[4.まとめ](#4-まとめ)
[5.おわりに](#5-おわりに)

# 成果物

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666916/a9e61c78-0bb8-47ae-4566-5963b8722e18.gif" width="400">


# 概要

## 実装したプロフィール画面で実装したい要件
- AppBarとTabBarの間にプロフィール画面を実装してスクロールした時にTabBarを上に固定
- タブを切り替えてもスクロールした位置を状態を維持

最初は[NestedScrollView](https://api.flutter.dev/flutter/widgets/NestedScrollView-class.html)で実装を試みたのですが、下記の2点のissueもあり今回はこのissueを改善した[ExtendedNestedScrollView](https://pub.dev/packages/extended_nested_scroll_view)を使用して実装しました。

https://github.com/flutter/flutter/issues/22393

https://github.com/flutter/flutter/issues/21868

# 実装手順
## ExtendedNestedScrollViewの追加
[extended_nested_scroll_view](https://pub.dev/packages/extended_nested_scroll_view)を`pubspec.yaml`に追加します。

```
dependencies:
  extended_nested_scroll_view: ^latest_version
```

## スクロールした時にTabBarを上に固定

`headerSliverBuilder`に、画面上部のプロフィール情報を表示し、スクロール時にタブが上部に固定されるように設定されています。
また、TabBarを継続的に表示させるために`SliverPersistentHeader`を使用することで実装しました。
`SliverPersistentHeader`の使用でAppBarとTabBarの間にプロフィール画面を実装も可能になります。

```dart
ExtendedNestedScrollView(
  // スクロール位置のすべてをまとめてスクロールするのを避ける
  onlyOneScrollInBody: true,
  // タブを上で固定するためにヘッダーの合計の高さを指定
  pinnedHeaderSliverHeightBuilder: () {
   return kToolbarHeight + MediaQuery.of(context).padding.top;
  },  
  headerSliverBuilder: (context, innerBoxIsScrolled) {
    return <Widget>[
      SliverAppBar(
        pinned: true, // スクロールした時に上にAppBarが表示されたままになる
        elevation: 0,
        title: Text(
          'プロフィール',
          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                color: Colors.white,
              ),
        ),
      ),
      // プロフィール画面
      const SliverToBoxAdapter(
        child: _ProfileTile(
          userName: '株式会社Never',
          userId: '@never_inc',
          selfIntroduction:
              '株式会社Neverとはモバイルアプリケーションをメインに開発、運用をおこなっております。',
        ),
      ),
      // タブの情報
      SliverPersistentHeader(
        pinned: true,
        delegate: _StickyTabBarDelegate(
          TabBar(
            controller: _tabController,
              tabs:
                TabItem.values.map((e) => Tab(text: e.title)).toList(),
              indicatorColor: Colors.blue,
              indicatorSize: TabBarIndicatorSize.label,
              labelColor: Colors.blue,
              labelPadding: EdgeInsets.zero,
          ),
        ),
      ),
    ],
  },
  // タブごとにウィジェットを設定
  body: TabBarView(
    controller: _tabController,
    children: <Widget>[
      ListView.separated(
        key: PageStorageKey<String>(TabItem.post.title),
        itemCount: 100,
        itemBuilder: (BuildContext context, int index) {
          return _PostTile(
            index: index,
            userName: '株式会社Never',
            userId: '@never_inc',
          );
        },
        separatorBuilder: (context, _) {
          return const Divider(height: 1);
        },
      ),
      ListView.separated(
        key: PageStorageKey<String>(TabItem.favorite.title),
        itemCount: 100,
        itemBuilder: (BuildContext context, int index) {
          return _PostTile(
            index: index,
            userName: 'HOGEHOGE',
            userId: '@hoge_hoge',
          );
        },
        separatorBuilder: (context, _) {
          return const Divider(height: 1);
        },
      ),
    ],
  ),
),
```

※ _ProfileTileについてはの実装は[こちら](https://github.com/never-inc/never_flutter_sample_code/blob/2d4d4a193fab7c5851b4cf332c3010e89ca931a7/lib/profile/screens/profile_screen.dart#L159
)
※ _PostTileについてはの実装は[こちら](https://github.com/never-inc/never_flutter_sample_code/blob/2d4d4a193fab7c5851b4cf332c3010e89ca931a7/lib/profile/screens/profile_screen.dart#L245
)


### _StickyTabBarDelegateについて

`SliverPersistentHeaderDelegate`を継承した`_StickyTabBarDelegate`クラスを使用して、TabBarの動作を制御します。このクラスでは、TabBarの高さを計算し、buildメソッドでTabBarを含むウィジェットを返します。
また、`shouldRebuild`では、古いデリゲートと新しいデリゲートが異なる場合にのみウィジェットを再構築するように設定します。

```dart
class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
  const _StickyTabBarDelegate(this.tabBar);

  final TabBar tabBar;

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

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

  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    return ColoredBox(
      color: Theme.of(context).scaffoldBackgroundColor,
      child: tabBar,
    );
  }

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

```


## タブを切り替えてもスクロールした位置を状態を維持

`ListView`に`PageStorageKey`を渡すことで、タブを切り替えるたびにスクロール位置を維持することができます。

```dart
ListView.separated(
  key: PageStorageKey<String>(TabItem.favorite.title),
  itemCount: 100,
  itemBuilder: (BuildContext context, int index) {
    return _PostTile(
      index: index,
      userName: '株式会社Never',
      userId: '@never_inc',
    );
  },
  separatorBuilder: (context, _) {
    return const Divider(height: 1);
  },
),
```



# まとめ
ExtendedNestedScrollViewを使用してプロフィール画面を作成する方法を説明しました。
画面をスクロールした時にタブを上に固定して、タブを切り替えた際にスクロール位置が保持されるプロフィール画面を作成することができました。

今回のソースコードはこちらのGitHubにて公開してます。

https://github.com/never-inc/never_flutter_sample_code/blob/main/lib/profile/screens/profile_screen.dart


# おわりに
最後までお読みいただきありがとうございます。
間違っている点などあればコメントにて指摘いただけますと幸いです。


アプリ設計や実装でお困りの際はお気楽にお問合せください。

https://neverjp.com/contact/