在 Flutter 中使用 NestedScrollView 與 CustomScrollView

最近在修改 app 的個人頁,遇到的需求:

  1. Tab bar 切出圓角
  2. Tab bar 可以滑動,滑到 app bar 的時候固定住

用文字說不太清楚,下面直接看圖:

Imgur

頁面的 components 有:

Tab view 裡是可滾動的內容, 這裡放了兩種類型:grid 與 list。 當用戶向上滑動時,app bar 固定不動,tab bar 往上滑動到 tab bar 時固定不動,下方 tab view 保持可滑動。如下圖:

Imgur

放上我的 demo project 以供參考: demo20220701

App bar 與個人資訊區

一般在網上看到的範例都是用 SliverAppBar,設定 pinnedtrue,然後將個人資訊區放到 flexibleSpace

但是實作的過程中發現,這個方法非常的不好用。原因在於 flexibleSpace 還需要搭配設定 expandedHeight 去指定高度。而我的個人資訊中包含了非固定長度的自介,因此就需要提前計算自介區的高度然後調整 expandedHeight

為了省麻煩,我決定將自介包裝成 sliver 放到 appbar 底下。

謎之音: 如果 flexibleSpace 只放頭像跟統計,那為何不乾脆放棄 flexibleSpace,全部都弄成 sliver,這樣連 expandedHeight 都可以省略了?

既然 app bar 與 tab bar 之間有 widgets,那麼 tab bar 就不能放到 app bar 的 bottom,它必須也是獨立的 sliver。

謎之音:既然 app bar 需要一直固定在上方,那現在還有需要使用 SliverAppBar 嗎?

Sliver Tab bar

Flutter 的 TabBar 並不是一個 sliver widget,再加上我需要讓 tab bar 在滑動的過程中可以固定到上方,因此就需要搭配使用 SliverPersistentHeader

SliverPersistentHeader 需要帶入 SliverPersistentHeaderDelegate 類型的 delegate。這裡我參考網路上的範例,做了一份自己的:

import 'package:flutter/material.dart';

class SliverTabBarDelegate extends SliverPersistentHeaderDelegate {
  final Color backgroundColor;
  final TabBar tabBar;
  final Radius topRadius;

  const SliverTabBarDelegate({
    required this.tabBar,
    required this.backgroundColor,
    required this.topRadius,
  });

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

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

  @override
  Widget build(context, shrinkOffset, overlapsContent) {
    return Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.only(
          topLeft: topRadius,
          topRight: topRadius,
        ),
        color: backgroundColor,
      ),
      child: tabBar,
    );
  }

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

有了 delegate 之後,就可以把 tab bar 製作成 sliver widget:

SliverPersistentHeader(
  delegate: SliverTabBarDelegate(
    tabBar: TabBar(controller: _tabController, tabs: _tabs),
    topRadius: _topRadius,
    backgroundColor: Colors.white,
  ),
  pinned: true,
)

CustomScrollView

看一下目前為止的架構:

Imgur

AppBar 是不是 sliver 已經無所謂了,個人還是喜歡原本的方式,因此不採用 SliverAppBar;Tab view 是不是 sliver 則視實作的方式決定。Profile 與 tab bar 則確定是 sliver widgets。

這種場景一般會直接用 CustomScrollView 建構頁面。例如:

SafeArea(
  bottom: false,
  child: DefaultTabController(
    initialIndex: 0,
    length: _tabs.length,
    child: CustomScrollView(
      slivers: [
        SliverToBoxAdapter(
          child: Profile(bio: bio, socialStatisticData: statistic),
        ),
        const SliverPersistentHeader(
          delegate: SliverTabBarDelegate(
            tabBar: TabBar(tabs: _tabs),
            topRadius: _topRadius,
            backgroundColor: Colors.white,
          ),
          pinned: true,
        ),
        const SliverFillRemaining(
          child: ColoredBox(
            color: Color.fromRGBO(234, 236, 238, 1),
            child: TabBarView(
              children: [
                ContentGrid(
                  itemCount: 50,
                  itemColor: Color.fromRGBO(245, 183, 177, 1),
                ),
                ContentList(
                  itemCount: 50,
                  itemColor: Color.fromRGBO(169, 223, 191, 1),
                )
              ],
            ),
          ),
        ),
      ],
    ),
  ),
)

但是這樣做會產生兩個問題:

  1. Scrolling 被切成了兩部份: profile + tab bar、TabBarViewTabBarView 中的 grid 與 list 上下滑動不會一起帶動 tab bar,這樣使用起來體驗很奇怪;但是如果在 grid/list 裡指定 physics: NeverScrollableScrollPhysics() 則會造成列表無法捲動到底部。
  2. 當 grid/list 往上滑時,本應該隱藏在 tab bar 後的內容,會出現在 tab bar 的圓角旁邊,這樣視覺上就很差:

Imgur

NestedScrollView + ValueListener

上面第一個問題很好解決,就是採用 NestedScrollView,將 profile 與 tab bar 放到 headerSliverBuilderTabBarView 直接放在 body 中,不需要包裝成 sliver。同時,任何 physics 都不需要調整。

但是第二個問題呢? 我本來想到的解決方案是:當 grid/list 內容捲到 tab bar 後面時,就同時一起切圓角:

import 'package:flutter/material.dart';

import 'models/statistics.dart';
import 'widgets/lists.dart';
import 'widgets/grids.dart';
import 'widgets/profiles.dart';
import 'widgets/utils/scaffolds.dart';
import 'widgets/utils/tabbars.dart';

const _tabs = [Tab(text: 'Grid'), Tab(text: 'List')];
const _topRadius = Radius.circular(40);

class MyHomePage extends StatefulWidget {
  final String bio;
  final String name;
  final SocialStatisticData statistic;
  const MyHomePage({
    required this.name,
    required this.bio,
    required this.statistic,
    Key? key,
  }) : super(key: key);

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final ValueNotifier<bool> _innerTabScrolled = ValueNotifier(false);

  @override
  Widget build(BuildContext context) {
    return ColorfulScaffold(
      backgroundColor: const Color.fromRGBO(250, 250, 250, 0.3),
      gradient: const LinearGradient(colors: [
        Color.fromRGBO(247, 220, 111, 1),
        Color.fromRGBO(243, 156, 18, 1),
      ]),
      appBar: AppBar(
        title: Text(widget.name),
        backgroundColor: Colors.transparent,
        elevation: 0,
      ),
      body: SafeArea(
        bottom: false,
        child: DefaultTabController(
          length: _tabs.length,
          child: NestedScrollView(
            headerSliverBuilder: (context, innerBoxScrolled) {
              _innerTabScrolled.value = innerBoxScrolled;
              return [
                SliverToBoxAdapter(
                  child: Profile(
                    bio: widget.bio,
                    socialStatisticData: widget.statistic,
                  ),
                ),
                const SliverPersistentHeader(
                  delegate: SliverTabBarDelegate(
                    tabBar: TabBar(tabs: _tabs),
                    topRadius: _topRadius,
                    backgroundColor: Colors.white,
                  ),
                  pinned: true,
                ),
              ];
            },
            body: ValueListenableBuilder<bool>(
              valueListenable: _innerTabScrolled,
              builder: (_, value, child) => ClipRRect(
                borderRadius: value
                    ? const BorderRadius.only(
                        topLeft: _topRadius,
                        topRight: _topRadius,
                      )
                    : BorderRadius.zero,
                child: child,
              ),
              child: const ColoredBox(
                color: Color.fromRGBO(234, 236, 238, 1),
                child: TabBarView(
                  children: [
                    ContentGrid(
                      itemCount: 50,
                      itemColor: Color.fromRGBO(245, 183, 177, 1),
                    ),
                    ContentList(
                      itemCount: 50,
                      itemColor: Color.fromRGBO(169, 223, 191, 1),
                    )
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

innerBoxScrolledtrue 時,就是 body 上滑到 headerSliverBuilder 的 slivers 裡了。這時候就裁切 TabBarView

理論上應該可以完美解決我的需求。但是,夢想很美好,現實很殘酷,跑出來的結果變成:

Imgur

實際上是 body 觸及頂部的時候,innerBoxScrolled 才變更為 true。於是就變成這樣瞬間切圓角的搞笑畫面。

這個是慢動作,滑快一點的話就看不出來了?! XD

NestedScrollView + CustomScrollView

在經過一番思考與嘗試之後,我將架構調整如下:

Imgur

這個方式有兩個重點:

兩者缺一不可,否則依然還是會有上下 scrolling 不同步的問題。

最終的程式碼:

import 'package:flutter/material.dart';

import 'models/statistics.dart';
import 'widgets/lists.dart';
import 'widgets/grids.dart';
import 'widgets/profiles.dart';
import 'widgets/utils/scaffolds.dart';
import 'widgets/utils/tabbars.dart';

const _tabs = [Tab(text: 'Grid'), Tab(text: 'List')];
const _topRadius = Radius.circular(20);

class MyHomePage extends StatefulWidget {
  final String bio;
  final String name;
  final SocialStatisticData statistic;
  const MyHomePage({
    required this.name,
    required this.bio,
    required this.statistic,
    Key? key,
  }) : super(key: key);

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final _scrollController = ScrollController();

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ColorfulScaffold(
      backgroundColor: const Color.fromRGBO(250, 250, 250, 0.3),
      gradient: const LinearGradient(colors: [
        Color.fromRGBO(247, 220, 111, 1),
        Color.fromRGBO(243, 156, 18, 1),
      ]),
      appBar: AppBar(
        title: Text(widget.name),
        backgroundColor: Colors.transparent,
        elevation: 0,
      ),
      body: SafeArea(
        bottom: false,
        child: NestedScrollView(
          controller: _scrollController,
          headerSliverBuilder: (context, _) => [
            SliverToBoxAdapter(
              child: Profile(
                bio: widget.bio,
                socialStatisticData: widget.statistic,
              ),
            ),
          ],
          body: ClipRRect(
            borderRadius: const BorderRadius.only(
              topLeft: _topRadius,
              topRight: _topRadius,
            ),
            child: ColoredBox(
              color: Colors.white,
              child: DefaultTabController(
                length: _tabs.length,
                child: CustomScrollView(
                  physics: const NeverScrollableScrollPhysics(),
                  controller: _scrollController,
                  slivers: const [
                    SliverPersistentHeader(
                      delegate: SliverTabBarDelegate(
                        tabBar: TabBar(tabs: _tabs),
                        topRadius: Radius.zero,
                        backgroundColor: Colors.white,
                      ),
                      pinned: true,
                    ),
                    SliverTabBarView(
                      color: Color.fromRGBO(234, 236, 238, 1),
                      children: [
                        ContentGrid(
                          itemCount: 50,
                          itemColor: Color.fromRGBO(245, 183, 177, 1),
                        ),
                        ContentList(
                          itemCount: 50,
                          itemColor: Color.fromRGBO(169, 223, 191, 1),
                        )
                      ],
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

最終的成果:

Imgur

參考資料