Flutter 入門指北(Part 8)之 Sliver 組件、NestedScrollView

該文已授權公眾號 「碼個蛋」,轉載請指明出處

上節最后留了個坑到這節來解決,因為涉及部件比較多,所以留到這邊來繼續講,不然寫太多了怕小伙伴看不下去

在上節最后,給小伙伴們展示了 SliveGridSliverFixedExtentList 的用法,基本上和 GridViewListView 的用法差不多,所以這邊就不多講這兩個部件了。

SliverAppBar

相信很多 Android 開發的小伙伴會用到 MaterialDesignCollapsingToolbarLayout 來實現折疊頭部,既然 Android 有的,那么 Flutter 也不會少,畢竟 Flutter 主打的也是 MaterialDesign 啊。首先看下 SliverAppBar 的源碼吧,其實和 AppBar 的參數差不多,只是多了一些比較特殊的屬性

const SliverAppBar({
    Key key,
    this.leading,
    this.automaticallyImplyLeading = true,
    this.title,
    this.actions,
    this.flexibleSpace, // 通過這個來設置背景
    this.bottom,
    this.elevation,
    this.forceElevated = false, // 是否顯示層次感
    this.backgroundColor,
    this.brightness,
    this.iconTheme,
    this.textTheme,
    this.primary = true,
    this.centerTitle,
    this.titleSpacing = NavigationToolbar.kMiddleSpacing,
    this.expandedHeight, // 展開的高度
    // 以下三個等例子再講
    this.floating = false, 
    this.pinned = false,
    this.snap = false,
  })

別的參數應該不陌生吧,都是 AppBar 的,那么直接來看個例子吧,還是通過上節說的 CustomScrollView 來包裹 Sliver 部件

class SliverDemoPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: CustomScrollView(slivers: <Widget>[
      SliverAppBar(
        title: Text('Sliver Demo'),
        centerTitle: true,
        // 展開的高度
        expandedHeight: 300.0,
        // 強制顯示陰影
        forceElevated: true,
        // 設置該屬性,當有下滑手勢的時候,就會顯示 AppBar
//        floating: true,
        // 該屬性只有在 floating 為 true 的情況下使用,不然會報錯
        // 當上滑到一定的比例,會自動把 AppBar 收縮(不知道是不是 bug,當 AppBar 下面的部件沒有被 AppBar 覆蓋的時候,不會自動收縮)
        // 當下滑到一定比例,會自動把 AppBar 展開
//        snap: true,
        // 設置該屬性使 Appbar 折疊后不消失
//        pinned: true,
        // 通過這個屬性設置 AppBar 的背景
        flexibleSpace: FlexibleSpaceBar(
//          title: Text('Expanded Title'),
          // 背景折疊動畫
          collapseMode: CollapseMode.parallax,
          background: Image.asset('images/timg.jpg', fit: BoxFit.cover),
        ),
      ),

      // 這個部件一般用于最后填充用的,會占有一個屏幕的高度,
      // 可以在 child 屬性加入需要展示的部件
      SliverFillRemaining(
        child: Center(child: Text('FillRemaining', style: TextStyle(fontSize: 30.0))),
      ),
    ]));
  }
}

這里分別給出不同的動圖來查看三個屬性的影響

如果設置了 floating 屬性,當有下拉動作時,會顯示 AppBar

floating.gif

如果設置了 snap 屬性,滑動距離達到一定值后,會根據滑動方向收縮或者展開

snap.gif

如果設置了 pinned 屬性,那么 AppBar 就會在界面上不會消失

pinned.gif

以上的效果圖把 SliverFillRemaining 換成列表 SliverFixedExtentList 效果可能會更加明顯,這邊給小伙伴自己替換測試吧。

SliverFillViewport

這邊提到了 SliverFillRemaining 用來填充視圖,那么順帶提下 SliverFillViewport 這個部件

const SliverFillViewport({
    Key key,
    @required SliverChildDelegate delegate, // 這個 delegate 同 SliverGrid 
    this.viewportFraction = 1.0, // 同屏幕的比例值,1.0 為一個屏幕大小
  })

如果一個滑動列表,每個 item 需要占滿一個屏幕或者更大,可以使用該部件生成列表,但是如果 item 的高度小于一個屏幕高度,那就不太推薦了,在首尾會用空白 item 來把未填滿的補上,就是首尾都會留空白。我們使用 SliverFillViewportSliverFillRemaning 進行替換

SliverFillViewport(
          viewportFraction: 1.0,
          delegate: SliverChildBuilderDelegate(
              (_, index) => Container(child: Text('Item $index'), alignment: Alignment.center, color: colors[index % 4]),
              childCount: 10))

效果就不展示了,可自行運行查看。

SliverToBoxAdapter

還記得上節最后的代碼中,有使用 SliverToBoxAdapter 這個部件嗎,這個部件只需要傳入一個 child 屬性。因為在 CustomScrollView 中只允許傳入 Sliver 部件,那么類似 Container 等普通部件就不可以使用了,那么這樣就需要更多的 Sliver 組件才能完成視圖,所以為了方便,直接通過 SliverToBoxAdapter 對普通部件進行包裹,這樣就成為一個 Sliver 部件了。總結下 SliverToBoxAdapter 的功能就是 把一個普通部件包裹成為 Sliver 部件,例子就不舉了,上節已經有了。

SliverPadding

那么在 CustomScrollView 中部件之間如何設置間距呢,可能你會想到用 SliverToBoxAdapter 包裹一個 Padding 來處理,當然沒問題。不過 Flutter 也提供了專門的部件 SliverPadding 使用方式同 Padding,但是需要傳入一個 sliver 作為子類。

SliverPersistentHeader

Flutter 中,為我們提供了這么一個作為頭部的部件 SliverPersistentHeader,這個部件可以根據滾動的距離縮小高度,有點類似 SliverAppBar 的背景效果。

const SliverPersistentHeader({
    Key key,
    @required this.delegate, // SliverPersistentHeaderDelegate,用來創建展示內容
    this.pinned = false, // 同 SliverAppBar 屬性
    this.floating = false,
  }) 
SliverPersistentHeaderDelegate

這個代理比較特殊,是個抽象類,也就是需要我們自己進行繼承后再實現方法。SliverPersistentHeaderDelegate 需要提供一個最大值,最小值,展示內容,以及更新部件條件

比如我們需要展示一個最大高度 300,最小高度 100,居中的文字,那么我們可以這么寫這個代理類

class DemoHeader extends SliverPersistentHeaderDelegate {
  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Container(
        color: Colors.pink,
        alignment: Alignment.center,
        child: Text('我是一個頭部部件', style: TextStyle(color: Colors.white, fontSize: 30.0)));
  } // 頭部展示內容

  @override
  double get maxExtent => 300.0; // 最大高度

  @override
  double get minExtent => 100.0; // 最小高度

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; // 因為所有的內容都是固定的,所以不需要更新
}

使用 SliverPersistentHeader 代替 SliverAppBar,看下效果

class SliverDemoPage extends StatelessWidget {
  final List<Color> colors = [Colors.red, Colors.green, Colors.blue, Colors.pink];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: CustomScrollView(slivers: <Widget>[
        SliverPersistentHeader(delegate: DemoHeader(), pinned: true),

      // 這個部件一般用于最后填充用的,會占有一個屏幕的高度,
      // 可以在 child 屬性加入需要展示的部件
          SliverFillRemaining(
            child: Center(child: Text('FillRemaining', style: TextStyle(fontSize: 30.0))),
          ),
    ]));
  }
}

最后的效果圖

header.gif

當然,為了方便擴展,需要重新封裝下 Delegate ,通過外部傳入范圍和展示內容

// 自定義 SliverPersistentHeaderDelegate
class CustomSliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
  final double max; // 最大高度
  final double min; // 最小高度
  final Widget child; // 需要展示的內容

  CustomSliverPersistentHeaderDelegate({@required this.max, @required this.min, @required this.child})
      // 如果 assert 內部條件不成立,會報錯
      : assert(max != null),
        assert(min != null),
        assert(child != null),
        assert(min <= max),
        super();

  // 返回展示的內容,如果內容固定可以直接在這定義,如果需要可擴展,這邊通過傳入值來定義
  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child;

  @override
  double get maxExtent => max; // 返回最大高度

  @override
  double get minExtent => min; // 返回最小高度

  @override
  bool shouldRebuild(CustomSliverPersistentHeaderDelegate oldDelegate) {
    // 是否需要更新,這里我們定義當高度范圍和展示內容被替換的時候進行刷新界面
    return max != oldDelegate.max || min != oldDelegate.min || child != oldDelegate.child;
  }
}

然后我們就可以愉快的使用了,不需要每個 Delegate 都重新寫一遍,例如替換下剛才寫死的 DemoHeader

SliverPersistentHeader(
        // 屬性同 SliverAppBar
        pinned: true,
        floating: true,
        // 因為 SliverPersistentHeaderDelegate 是一個抽象類,所以需要自定義
        delegate: CustomSliverPersistentHeaderDelegate(
            max: 300.0, min: 100.0, child: Text('我是一個頭部部件', style: TextStyle(color: Colors.white, fontSize: 30.0))),
      ),

例如需要替換成一張圖片,直接將 Text 修改成 Image 即可。

以上部分代碼查看 sliver_main.dart 文件

NestedScrollView

講到這了,不得不提下 Scrollable 中比較重要的一員 NestedScrollView,先看下官方的解釋

/// A scrolling view inside of which can be nested other scrolling views, with
/// their scroll positions being intrinsically linked.

糟透了的翻譯 X 1:一個內部能夠嵌套其他滾動部件,并使其滾動位置聯結到一起的滾動部件

/// The most common use case for this widget is a scrollable view with a
/// flexible [SliverAppBar] containing a [TabBar] in the header (build by
/// [headerSliverBuilder], and with a [TabBarView] in the [body], such that the
/// scrollable view's contents vary based on which tab is visible.

糟透了的翻譯 X 2:最常用的情況,就是在其 headerSliverBuilder 中使用攜帶 TabBarSliverAppBar(就是使用 SliverAppBarbottom 屬性添加 tab 切換也),其 body 屬性使用 TabBarView 來展示 Tab 頁的內容,這樣通過切換 Tab 頁就能展示該頁下的展示內容。

看下 headerSliverBuilder 的定義

/// Signature used by [NestedScrollView] for building its header.
///
/// The `innerBoxIsScrolled` argument is typically used to control the
/// [SliverAppBar.forceElevated] property to ensure that the app bar shows a
/// shadow, since it would otherwise not necessarily be aware that it had
/// content ostensibly below it.
typedef NestedScrollViewHeaderSliversBuilder = List<Widget> Function(BuildContext context, bool innerBoxIsScrolled);

糟透了的翻譯 X 3:用于構建 NestScrollView 的頭部部件,innerBoxIsScrolled 主要用來控制 SliverAppBarforceElevated 屬性,當內部內容滾動時,顯示 SliverAppbar 的陰影,主要用來提醒內部的內容低于 SliverAppBar (相當于給人一種物理層次感,否則很容易被認為,頭部和內容是連接在一起的)

接下來看下 NestedScrollView 內部個人覺得有點重要的一個方法 sliverOverlapAbsorberHandleFor

/// Returns the [SliverOverlapAbsorberHandle] of the nearest ancestor
/// [NestedScrollView].
///
/// This is necessary to configure the [SliverOverlapAbsorber] and
/// [SliverOverlapInjector] widgets.
///
/// For sample code showing how to use this method, see the [NestedScrollView]
/// documentation.
static SliverOverlapAbsorberHandle sliverOverlapAbsorberHandleFor(BuildContext context) {
  final _InheritedNestedScrollView target = context.inheritFromWidgetOfExactType(_InheritedNestedScrollView);
  assert(target != null, 'NestedScrollView.sliverOverlapAbsorberHandleFor must be called with a context that contains a NestedScrollView.');
  return target.state._absorberHandle;
}

請注意到中間的注釋

糟透了的翻譯 X 4:這個方法返回的值對于 SliverOverlapAbsorberSliverOverlapInjector 部件是非常重要的參數

接著請注意代碼中的那段 assert 中的文字

糟透了的翻譯 X 5:sliverOverlapAbsorberHandleFor 傳入的參數 context 中必須包含 NestedScrollView

SliverOverlapAbsorber

這邊又引入了兩個部件 SliverOverlapAbsorber + SliverOverlapInjector 還是看源碼的解釋吧

/// Creates a sliver that absorbs overlap and reports it to a
/// [SliverOverlapAbsorberHandle].
///
/// The [handle] must not be null.
///
/// The [child] must be a sliver.
const SliverOverlapAbsorber({
  Key key,
  @required this.handle,
  Widget child,
}) 

糟透了的翻譯 X 6:一個 sliver 部件,用于把部件重疊的高度反饋給 SliverOverlapAbsorberHandle,而且指明了 handle 不能空,可以通過 NestedScrollViewsliverOverlapAbsorberHandleFor 方法來賦值,并且 child 必須是個 sliver 部件,也就是說我們的 SliverAppBar 需要放到 SliverOverlapAbsorber 里面。

SliverOverlapInjector
/// Creates a sliver that is as tall as the value of the given [handle]'s
/// layout extent.
///
/// The [handle] must not be null.
const SliverOverlapInjector({
  Key key,
  @required this.handle,
  Widget child,
})

糟透了的翻譯 X 7:創建一個和指定的 handle 一樣高度的 sliver 部件,這個 handleSliverOverlapAbsorberhandle 保持一致即可。

分析完源碼后,例子的目標很明確,使用 SliverAppBar + TabBar + TabBarView,先看下最后的效果圖吧

nested.gif
class NestedScrollDemoPage extends StatelessWidget {
  final _tabs = <String>['TabA', 'TabB'];
  final colors = <Color>[Colors.red, Colors.green, Colors.blue, Colors.pink, Colors.yellow, Colors.deepPurple];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: DefaultTabController(
          length: _tabs.length,
          child: NestedScrollView(
              headerSliverBuilder: (context, innerScrolled) => <Widget>[
                    SliverOverlapAbsorber(
                      // 傳入 handle 值,直接通過 `sliverOverlapAbsorberHandleFor` 獲取即可
                      handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                      child: SliverAppBar(
                        pinned: true,
                        title: Text('NestedScroll Demo'),
                        expandedHeight: 200.0,
                        flexibleSpace: FlexibleSpaceBar(background: Image.asset('images/timg.jpg', fit: BoxFit.cover)),
                        bottom: TabBar(tabs: _tabs.map((tab) => Text(tab, style: TextStyle(fontSize: 18.0))).toList()),
                        forceElevated: innerScrolled,
                      ),
                    )
                  ],
              body: TabBarView(
                  children: _tabs
                      // 這邊需要通過 Builder 來創建 TabBarView 的內容,否則會報錯
                      // NestedScrollView.sliverOverlapAbsorberHandleFor must be called with a context that contains a NestedScrollView.
                      .map((tab) => Builder(
                            builder: (context) => CustomScrollView(
                                  // key 保證唯一性
                                  key: PageStorageKey<String>(tab),
                                  slivers: <Widget>[
                                    // 將子部件同 `SliverAppBar` 重疊部分頂出來,否則會被遮擋
                                    SliverOverlapInjector(
                                        handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
                                    SliverGrid(
                                        delegate: SliverChildBuilderDelegate(
                                            (_, index) => Image.asset('images/ali.jpg'),
                                            childCount: 8),
                                        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                                            crossAxisCount: 4, mainAxisSpacing: 10.0, crossAxisSpacing: 10.0)),
                                    SliverFixedExtentList(
                                        delegate: SliverChildBuilderDelegate(
                                            (_, index) => Container(
                                                child: Text('$tab - item${index + 1}',
                                                    style: TextStyle(fontSize: 20.0, color: colors[index % 6])),
                                                alignment: Alignment.center),
                                            childCount: 15),
                                        itemExtent: 50.0)
                                  ],
                                ),
                          ))
                      .toList()))),
    );
  }
}

使用的部件和之前講的沒啥大區別,就是多了 SliverOverlapAbsorberSliverOverlapInjector 沒啥難度

以上部分代碼查看 nested_scroll_main.dart 文件

sliver 部件常用的也就那么多了,望小伙伴好好吸收,跟著例子擼擼代碼,擼順下思路

最后代碼的地址還是要的:

  1. 文章中涉及的代碼:demos

  2. 基于郭神 cool weather 接口的一個項目,實現 BLoC 模式,實現狀態管理:flutter_weather

  3. 一個課程(當時買了想看下代碼規范的,代碼更新會比較慢,雖然是跟著課上的一些寫代碼,但是還是做了自己的修改,很多地方看著不舒服,然后就改成自己的實現方式了):flutter_shop

如果對你有幫助的話,記得給個 Star,先謝過,你的認可就是支持我繼續寫下去的動力~

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容