在Flutter中建立有意思的滾動效果 - Sliver系列

SmallStoneSK發表於2019-07-29

1. 前言

Flutter作為時下最流行的技術之一,憑藉其出色的效能以及抹平多端的差異優勢,早已引起大批技術愛好者的關注,甚至一些閒魚美團騰訊等大公司均已投入生產使用。雖然目前其生態還沒有完全成熟,但身靠背後的Google加持,其發展速度已經足夠驚人,可以預見將來對Flutter開發人員的需求也會隨之增長。

無論是為了技術嚐鮮還是以後可能的工作機會,都9102年了,作為一個前端開發者,似乎沒有理由不去嘗試它。正是帶著這樣的心理,筆者也開始學習Flutter,同時建了一個用於練習的倉庫,後續所有程式碼都會託管在上面,歡迎star,一起學習。這是我寫的Flutter系列文章:

在之前的文章中,我們學習瞭如何使用ListViewGridView這兩個滾動型別元件。今天,我們就來學習另一個滾動元件CustomScrollView及其搭配使用的Sliver系列元件。掌握了它們,你就可以做一些有趣的滾動效果啦~

2. 必備知識

在進入今天的正題之前,我們先來簡單瞭解下今天的兩個主角CustomScrollViewSliverCustomScrollViewFlutter提供的可以用來自定義滾動效果的元件,它可以像膠水一樣將多個Sliver粘合在一起。

什麼意思呢?舉個栗子(你也可以點選這裡youtube上的一個視訊):

假如頁面中同時存在一個List和一個Grid,雖然它們看起來是一個整體,但是由於各自的滾動效果是分離的,所以沒法保證一致的滾動效果。

而使用CustomScrollView元件作為滾動容器,SliverListSliverGrid分別替代ListGrid作為CustomScrollView的子元件,滾動效果再由CustomScrollView統一控制,這樣就可以了。

其中SliverListSliverGrid就是我們前面提到的Sliver系列中的兩員,除此之外,Sliver家族還有常用的幾個:

  • SliverAppBar:Creates a material design app bar that can be placed in a CustomScrollView.
  • SliverPersistentHeader:Creates a sliver that varies its size when it is scrolled to the start of a viewport.
  • SliverFillRemaining:Creates a sliver that fills the remaining space in the viewport.
  • SliverToBoxAdapter:Creates a sliver that contains a single box widget.
  • SliverPadding:Creates a sliver that applies padding on each side of another sliver.

注意:由於CustomeScrollView的子元件只能是Sliver系列,所以如果你想將一個普通元件塞進CustomScrollView,那麼務必將該元件用SliverToBoxAdapter包裹。

3. 熱身:SliverList / SliverGrid

前面講了那麼多的概念似乎有些枯燥,接下來就讓我們從最簡單的一個例子入手來看看如何使用CustomScrollViewSliverList/SliverGrid

其實CustomScrollView的用法很簡單,它有一個slivers屬性,是一個Widget陣列,將子元件都放在裡面就可以了,其他的一些滾動相關的屬性基本和我們之前學到的ListView差不多。

CustomScrollView(
  slivers: <Widget>[
    renderSliverA(),
    renderSliverB(),
    renderSliverC(),
  ],
)
複製程式碼

再來看看SliverList,它只有一個delegate屬性,可以用SliverChildListDelegateSliverChildBuilderDelegate這兩個類實現。前者將會一次性全部渲染子元件,後者將會根據視窗渲染當前出現的元素,其效果可以和ListViewListView.build這兩個建構函式類比。

SliverList(
  delegate: SliverChildListDelegate(
    <Widget>[
      renderA(),
      renderB(),
      renderC(),
    ]
  )
)

SliverList(
  delegate: SliverChildBuilderDelegate(
    (context, index) => renderItem(context, index),
    childCount: 10,
  )
)
複製程式碼

通過上面的例子我們發現SliverList的使用方式和ListView大同小異,而SliverGrid也是如此,這裡就不再過多贅述,來看個兩列網格的例子:

SliverGrid.count(
  crossAxisCount: 2,
  children: <Widget>[
    renderA(),
    renderB(),
    renderC(),
    renderD()
  ]
)
複製程式碼

接下來,就讓我們通過一個實際例子將上面的三點結合在一起。

程式碼(完整版看這裡

final List<Color> colorList = [
  Colors.red,
  Colors.orange,
  Colors.green,
  Colors.purple,
  Colors.blue,
  Colors.yellow,
  Colors.pink,
  Colors.teal,
  Colors.deepPurpleAccent
];

// Text元件需要用SliverToBoxAdapter包裹,才能作為CustomScrollView的子元件
Widget renderTitle(String title) {
  return SliverToBoxAdapter(
    child: Padding(
      padding: EdgeInsets.symmetric(vertical: 16),
      child: Text(
        title,
        textAlign: TextAlign.center,
        style: TextStyle(fontSize: 20),
      ),
    ),
  );
}

CustomScrollView(
  slivers: <Widget>[
    renderTitle('SliverGrid'),
    SliverGrid.count(
      crossAxisCount: 3,
      children: colorList.map((color) => Container(color: color)).toList(),
    ),
    renderTitle('SliverList'),
    SliverFixedExtentList(		// SliverList的語法糖,用於每個item固定高度的List
      delegate: SliverChildBuilderDelegate(
        (context, index) => Container(color: colorList[index]),
        childCount: colorList.length,
      ),
      itemExtent: 100,
    ),
  ],
)
複製程式碼

效果圖

在Flutter中建立有意思的滾動效果 - Sliver系列

上面的例子中還有一點需要注意的是:我們將標題元件放在了SliverToBoxAdapter內,因為CustomScrollView只接受Sliver系列的元件。

4. 眼前一亮的SliverAppBar

AppBar是常用來構建一個頁面頭部Bar的元件,在CustomScrollView中與其對應的是SliverAppBar元件。它有什麼神奇之處呢?隨著頁面的滾動,頭部Bar將會有一個收起過渡的效果。我們先來看下效果:

float效果 snap效果 pinned效果
float
snap
pinned

通過上面的預覽圖,想必你肯定很好奇SliverAppBar中的過渡效果是如何實現的~先別急,我們先來看下應該如何使用它:

SliverAppBar(
  floating: true,
  snap: true,
  pinned: true,
  expandedHeight: 250,
  flexibleSpace: FlexibleSpaceBar(
    title: Text(this.title),
    background: Image.network(
      'http://img1.mukewang.com/5c18cf540001ac8206000338.jpg',
      fit: BoxFit.cover,
    ),
  ),
)
複製程式碼

SliverAppBar最重要的幾個屬性在上面的例子中羅列出來。其中:

  • expandedHeight:展開狀態下appBar的高度,即圖中圖片所佔空間;
  • flexibleSpace:空間大小可變的元件,Flutter給我們提供了一個現成的FlexibleSpaceBar元件,給我們處理好了title過渡的效果。

另外,floating/snap/pinned這三個屬性可以指定SliverAppBar內容滑出螢幕之後的表現形式。

  • float:向下滑動時,即使當前CustomScrollView不在頂部,SliverAppBar也會跟著一起向下出現;
  • snap:當手指放開時,SliverAppBar會根據當前的位置進行調整,始終保持展開收起的狀態;
  • pinned:不同於float效果,當SliverAppBar內容滑出螢幕時,將始終渲染一個固定在頂部的收起狀態元件。

需要注意的是:snap效果一定要在floattrue時才會生效。另外,你也可以將這三者進行組合使用。

5. 花樣多變的SliverPersistentHeader

在上一小節中我們見識到了SliverAppBar的神奇之處,其實它就是基於SliverPersistentHeader實現的。通過SliverPersistentHeader,我們還可以實現sticky吸頂的效果。

SliverPersistentHeader最重要的一個屬性是SliverPersistentHeaderDelegate,為此我們需要實現一個類繼承自SliverPersistentHeaderDelegate

class StickyTabBarDelegate extends SliverPersistentHeaderDelegate {

  @override
  double get minExtent => null;

  @override
  double get maxExtent => null;

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => null;
  
  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => null;
}
複製程式碼

可以看到,SliverPersistentHeaderDelegate的實現類必須實現其4個方法。其中:

  • minExtent:收起狀態下元件的高度;
  • maxExtent:展開狀態下元件的高度;
  • shouldRebuild:類似於react中的shouldComponentUpdate
  • build:構建渲染的內容。

接下來,我們就來實現一個TabBar吸頂的效果。

程式碼(完整版看這裡

CustomScrollView(
  slivers: <Widget>[
    SliverAppBar(
      // ...
    ),
    SliverPersistentHeader(	// 可以吸頂的TabBar
      pinned: true,
      delegate: StickyTabBarDelegate(
        child: TabBar(
          labelColor: Colors.black,
          controller: this.tabController,
          tabs: <Widget>[
            Tab(text: 'Home'),
            Tab(text: 'Profile'),
          ],
        ),
      ),
    ),
    SliverFillRemaining(		// 剩餘補充內容TabBarView
      child: TabBarView(
        controller: this.tabController,
        children: <Widget>[
          Center(child: Text('Content of Home')),
          Center(child: Text('Content of Profile')),
        ],
      ),
    ),
  ],
)

class StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
  final TabBar child;

  StickyTabBarDelegate({@required this.child});

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return this.child;
  }

  @override
  double get maxExtent => this.child.preferredSize.height;

  @override
  double get minExtent => this.child.preferredSize.height;

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return true;
  }
}
複製程式碼

效果圖

在Flutter中建立有意思的滾動效果 - Sliver系列

根據上面的圖我們可以看到,當下方tab內容滑出螢幕後,tabBar並沒有跟著一起滑走,而是粘在了頂部。可見SliverPersistentHeader的確可以滿足我們的sticky效果。

不過SliverPersistentHeader的神奇可遠不止如此哦~我們可以通過它自定義一些頭部的過渡效果,畢竟SliverAppBar也是通過它實現的。就比如下方這個電影詳情頁的頭部過渡效果,這在一般的app種還是比較常見的。

在Flutter中建立有意思的滾動效果 - Sliver系列

那麼這種效果要如何實現呢?關鍵就在於build方法中的shrinkOffset屬性,它代表當前頭部的滾動偏移量。我們可以根據它計算得到當前收起頭部的背景顏色以及圖示和文案的字型顏色,這樣就能根據當前位置得到過渡效果啦~

程式碼(完整版看這裡

class SliverCustomHeaderDelegate extends SliverPersistentHeaderDelegate {
  final double collapsedHeight;
  final double expandedHeight;
  final double paddingTop;
  final String coverImgUrl;
  final String title;

  SliverCustomHeaderDelegate({
    this.collapsedHeight,
    this.expandedHeight,
    this.paddingTop,
    this.coverImgUrl,
    this.title,
  });

  @override
  double get minExtent => this.collapsedHeight + this.paddingTop;

  @override
  double get maxExtent => this.expandedHeight;

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return true;
  }

  Color makeStickyHeaderBgColor(shrinkOffset) {
    final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255).clamp(0, 255).toInt();
    return Color.fromARGB(alpha, 255, 255, 255);
  }

  Color makeStickyHeaderTextColor(shrinkOffset, isIcon) {
    if(shrinkOffset <= 50) {
      return isIcon ? Colors.white : Colors.transparent;
    } else {
      final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255).clamp(0, 255).toInt();
      return Color.fromARGB(alpha, 0, 0, 0);
    }
  }

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Container(
      height: this.maxExtent,
      width: MediaQuery.of(context).size.width,
      child: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          // 背景圖
          Container(child: Image.network(this.coverImgUrl, fit: BoxFit.cover)),
          // 收起頭部
          Positioned(
            left: 0,
            right: 0,
            top: 0,
            child: Container(
              color: this.makeStickyHeaderBgColor(shrinkOffset),	// 背景顏色
              child: SafeArea(
                bottom: false,
                child: Container(
                  height: this.collapsedHeight,
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: <Widget>[
                      IconButton(
                        icon: Icon(
                          Icons.arrow_back_ios,
                          color: this.makeStickyHeaderTextColor(shrinkOffset, true),	// 返回圖示顏色
                        ),
                        onPressed: () => Navigator.pop(context),
                      ),
                      Text(
                        this.title,
                        style: TextStyle(
                          fontSize: 20,
                          fontWeight: FontWeight.w500,
                          color: this.makeStickyHeaderTextColor(shrinkOffset, false),	// 標題顏色
                        ),
                      ),
                      IconButton(
                        icon: Icon(
                          Icons.share,
                          color: this.makeStickyHeaderTextColor(shrinkOffset, true),	// 分享圖示顏色
                        ),
                        onPressed: () {},
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
複製程式碼

上面的程式碼雖然很長,但大部分是構建widget的程式碼。所以,我們重點關注makeStickyHeaderTextColormakeStickyHeaderBgColor即可。這兩個方法都是根據當前的shrinkOffset值計算過渡過程中的顏色值。另外,這裡需要注意頭部在iPhoneX及以上的劉海頭設計,可以用SafeArea元件解決問題。

6. 總結

本文首先介紹了CustomScrollViewSliver系列元件的概念及其關係,接著以SliverListSliverGrid結合的示例說明了其使用方法。然後,又介紹了較常用的SliverAppBar元件,分別解釋了其float/snap/pinned各自的效果。最後,講解了SliverPersistentHeader元件的使用方法,並用實際例子加以說明其自定義過渡效果的用法。希望通過本文的介紹,你可以用CustomScrollViewSliver系列元件建立出更有意思的滾動效果~

本文所有程式碼託管在這兒,也可以關注我的Blog,歡迎一起交流學習~

相關文章