首先,我們看看目標(biāo)和實(shí)現(xiàn)效果


我這邊是把放活動的地方放在了TabBar上方。至于為什么,哈哈,我怕麻煩,因?yàn)槊缊F(tuán)外賣的放活動的組件和下方商品的組件一并點(diǎn)菜、評價、商家頁面的切換而消失,但是這玩意兒又隨商品頁面的上滑而消失,算上主滑動組件,我們得做讓從商品列表組件上的滑動穿透兩級,實(shí)在是麻煩。所以我便把活動的組件放在了TabBar上方。
然后我們來分析一下頁面結(jié)構(gòu)


看了前面的動態(tài)圖片,我們知道,TabBar下方的內(nèi)容(即結(jié)構(gòu)圖中的Body部分)隨頁面上滑而延伸,內(nèi)部也包括了滑動組件??吹竭@種結(jié)構(gòu),我們自然很容易想到NestedScrollView這個組件。但是直接使用NestedScrollView有一些問題。舉個例子,先看例子代碼:
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool boxIsScrolled) {
return <Widget>[
SliverAppBar(
pinned: true,
title: Text("首頁",style: TextStyle(color: Colors.black)),
backgroundColor: Colors.transparent,
bottom: TabBar(
controller: _tabController,
labelColor: Colors.black,
tabs: <Widget>[
Tab(text: "商品"),
Tab(text: "評價"),
Tab(text: "商家"),
],
),
)
];
},
body: Container(
color: Colors.blue,
child: Center(
child: Text("Body部分"),
),
),
),
);
}

看代碼,我將
SliverAppBar的背景設(shè)置為透明。當(dāng)頁面上滑的時候,問題出現(xiàn)了,Body部分穿過了SliverAppBar和狀態(tài)欄下方,到達(dá)了屏幕頂部。這樣的話,做出來的效果肯定不是我們想要的。另外,由于NestedScrollView內(nèi)部里面只有一個ScrollController(下方代碼中的innerController),Body里面的所有列表的ScrollPosition都將會attach到這個ScrollController上,那么就又有問題了,我們的商品頁面里面有兩個列表,如果共用一個控制器,那么ScrollPosition也使用的同一個,這可不行啊,畢竟列表都不一樣,所以因?yàn)?code>NestedScrollView內(nèi)部里面只有一個ScrollController這一點(diǎn),就決定了我們不能憑借NestedScrollView來實(shí)現(xiàn)這個效果。但是,NestedScrollView對我們也不是沒有用,它可是為我們提供了關(guān)鍵思路。為什么說
NestedScrollView依然對我們有用呢?因?yàn)樗奶匦匝剑?code>Body部分會隨頁面上滑而延伸,Body部分的底部始終在屏幕的底部。那么這個Body部分的高度是怎么來的?我們?nèi)タ纯?code>NestedScrollView的代碼:
List<Widget> _buildSlivers(BuildContext context,
ScrollController innerController, bool bodyIsScrolled) {
return <Widget>[
...headerSliverBuilder(context, bodyIsScrolled),
SliverFillRemaining(
child: PrimaryScrollController(
controller: innerController,
child: body,
),
),
];
}
NestedScrollView的body放到了SliverFillRemaining中,而這SliverFillRemaining的的確確是NestedScrollView的body能夠填滿在前方組件于NestedScrollView底部之間的關(guān)鍵。好的,知道了這家伙的存在,我們可以試試自己來做一個跟NestedScrollView有些類似的效果了。我選擇了最外層滑動組件CustomScrollView,嘿嘿,NestedScrollView也是繼承至CustomScrollView來實(shí)現(xiàn)的。
實(shí)現(xiàn)一個 NestedScrollView 類似的效果
首先我們寫一個跟NestedScrollView結(jié)構(gòu)類似的界面ShopPage出來,關(guān)鍵代碼如下:
class _ShopPageState extends State<ShopPage>{
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
controller: _pageScrollController,
physics: AlwaysScrollableScrollPhysics(),
slivers: <Widget>[
SliverAppBar(
pinned: true,
title: Text("店鋪首頁", style: TextStyle(color: Colors.white)),
backgroundColor: Colors.blue,
expandedHeight: 300),
SliverFillRemaining(
child: ListView.builder(
controller: _childScrollController,
padding: EdgeInsets.all(0),
physics: AlwaysScrollableScrollPhysics(),
shrinkWrap: true,
itemExtent: 100.0,
itemCount: 30,
itemBuilder: (context, index) => Container(
padding: EdgeInsets.symmetric(horizontal: 1),
child: Material(
elevation: 4.0,
borderRadius: BorderRadius.circular(5.0),
color:
index % 2 == 0 ? Colors.cyan : Colors.deepOrange,
child: Center(child: Text(index.toString())),
))))
],
),
);
}
}


由動圖可以看到,滑動下面的ListView不能帶動CustomScrollView中的SliverAppBar伸縮。我們應(yīng)該怎么實(shí)現(xiàn)呢?首先想想我們要的效果:
- 向上滑動
ListView時,如果SliverAppBar是展開狀態(tài),應(yīng)該先讓SliverAppBar收縮,當(dāng)SliverAppBar不能收縮時,ListView才會滾動。 - 向下滑動
ListView時,當(dāng)ListView已經(jīng)滑動到第一個不能再滑動時,SliverAppBar應(yīng)該展開,直到SliverAppBar完全展開。
SliverAppBar應(yīng)不應(yīng)該響應(yīng),響應(yīng)的話是展開還是收縮。我們肯定需要根據(jù)滑動方向和CustomScrollView與ListView已滑動距離來判斷。所以我們需要一個工具來根據(jù)滑動事件是誰發(fā)起的、CustomScrollView與ListView的狀態(tài)、滑動的方向、滑動的距離、滑動的速度等進(jìn)行協(xié)調(diào)它們怎么響應(yīng)。
至于這個協(xié)調(diào)器怎么寫,我們先不著急。我們應(yīng)該搞清楚 滑動組件原理,推薦文章:
從零開始實(shí)現(xiàn)一個嵌套滑動的PageView(一)
從零開始實(shí)現(xiàn)一個嵌套滑動的PageView(二)
Flutter的滾動以及sliver約束
看了這幾個文章,結(jié)合我們的使用場景,我們需要明白:
- 當(dāng)手指在屏幕上滑動時,
ScrollerPosition中的applyUserOffset方法會得到滑動矢量; - 當(dāng)手指離開屏幕時,
ScrollerPosition中的goBallistic方法會得到手指離開屏幕前滑動速度; - 至始自終,主滑動組件上發(fā)起的滑動事件,對子滑動部件無干擾,那么我們在協(xié)調(diào)時,只需要把子部件的事件傳給協(xié)調(diào)器分析、協(xié)調(diào)。
簡單來說,我們需要修改 ScrollerPosition, ScrollerController。修改ScrollerPosition是為了把手指滑動距離或手指離開屏幕前滑動速度傳遞給協(xié)調(diào)器協(xié)調(diào)處理。修改ScrollerController是為了保證滑動控制器在創(chuàng)建ScrollerPosition創(chuàng)建的是我們修改過后的ScrollerPosition。那么,開始吧!
實(shí)現(xiàn)子部件上下滑動關(guān)聯(lián)主部件
首先,假設(shè)我們的協(xié)調(diào)器類名為ShopScrollCoordinator。
滑動控制器 ShopScrollerController
我們?nèi)?fù)制ScrollerController的源碼,然后為了方便區(qū)分,我們把類名改為ShopScrollController。
控制器需要修改的部分如下:
class ShopScrollController extends ScrollController {
final ShopScrollCoordinator coordinator;
ShopScrollController(
this.coordinator, {
double initialScrollOffset = 0.0,
this.keepScrollOffset = true,
this.debugLabel,
}) : assert(initialScrollOffset != null),
assert(keepScrollOffset != null),
_initialScrollOffset = initialScrollOffset;
ScrollPosition createScrollPosition(ScrollPhysics physics,
ScrollContext context, ScrollPosition oldPosition) {
return ShopScrollPosition(
coordinator: coordinator,
physics: physics,
context: context,
initialPixels: initialScrollOffset,
keepScrollOffset: keepScrollOffset,
oldPosition: oldPosition,
debugLabel: debugLabel,
);
}
///其他的代碼不要動
}
滑動滾動位置 ShopScrollPosition
原版的ScrollerController創(chuàng)建的ScrollPosition 是 ScrollPositionWithSingleContext。
我們?nèi)?fù)制ScrollPositionWithSingleContext的源碼,然后為了方便區(qū)分,我們把類名改為ShopScrollPosition。前面說了,我們主要是需要修改applyUserOffset,goBallistic兩個方法。
class ShopScrollPosition extends ScrollPosition
implements ScrollActivityDelegate {
final ShopScrollCoordinator coordinator; // 協(xié)調(diào)器
ShopScrollPosition(
{@required this.coordinator,
@required ScrollPhysics physics,
@required ScrollContext context,
double initialPixels = 0.0,
bool keepScrollOffset = true,
ScrollPosition oldPosition,
String debugLabel})
: super(
physics: physics,
context: context,
keepScrollOffset: keepScrollOffset,
oldPosition: oldPosition,
debugLabel: debugLabel,
) {
if (pixels == null && initialPixels != null) correctPixels(initialPixels);
if (activity == null) goIdle();
assert(activity != null);
}
/// 當(dāng)手指滑動時,該方法會獲取到滑動距離
/// [delta]滑動距離,正增量表示下滑,負(fù)增量向上滑
/// 我們需要把子部件的 滑動數(shù)據(jù) 交給協(xié)調(diào)器處理,主部件無干擾
@override
void applyUserOffset(double delta) {
ScrollDirection userScrollDirection =
delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse;
if (debugLabel != coordinator.pageLabel)
return coordinator.applyUserOffset(delta, userScrollDirection, this);
updateUserScrollDirection(userScrollDirection);
setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta));
}
/// 以特定的速度開始一個物理驅(qū)動的模擬,該模擬確定[pixels]位置。
/// 此方法遵從[ScrollPhysics.createBallisticSimulation],該方法通常在當(dāng)前位置超出
/// 范圍時提供滑動模擬,而在當(dāng)前位置超出范圍但具有非零速度時提供摩擦模擬。
/// 速度應(yīng)以每秒邏輯像素為單位。
/// [velocity]手指離開屏幕前滑動速度,正表示下滑,負(fù)向上滑
@override
void goBallistic(double velocity, [bool fromCoordinator = false]) {
if (debugLabel != coordinator.pageLabel) {
// 子部件滑動向上模擬滾動時才會關(guān)聯(lián)主部件
if (velocity > 0.0) coordinator.goBallistic(velocity);
} else {
if (fromCoordinator && velocity <= 0.0) return;
}
assert(pixels != null);
final Simulation simulation =
physics.createBallisticSimulation(this, velocity);
if (simulation != null) {
beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
} else {
goIdle();
}
}
/// 返回未使用的增量。
/// 從[NestedScrollView]的自定義[ScrollPosition][_NestedScrollPosition]拷貝
double applyClampedDragUpdate(double delta) {
assert(delta != 0.0);
final double min =
delta < 0.0 ? -double.infinity : math.min(minScrollExtent, pixels);
final double max =
delta > 0.0 ? double.infinity : math.max(maxScrollExtent, pixels);
final double oldPixels = pixels;
final double newPixels = (pixels - delta).clamp(min, max) as double;
final double clampedDelta = newPixels - pixels;
if (clampedDelta == 0.0) return delta;
final double overScroll = physics.applyBoundaryConditions(this, newPixels);
final double actualNewPixels = newPixels - overScroll;
final double offset = actualNewPixels - oldPixels;
if (offset != 0.0) {
forcePixels(actualNewPixels);
didUpdateScrollPositionBy(offset);
}
return delta + offset;
}
/// 返回過度滾動。
/// 從[NestedScrollView]的自定義[ScrollPosition][_NestedScrollPosition]拷貝
double applyFullDragUpdate(double delta) {
assert(delta != 0.0);
final double oldPixels = pixels;
// Apply friction: 施加摩擦:
final double newPixels =
pixels - physics.applyPhysicsToUserOffset(this, delta);
if (oldPixels == newPixels) return 0.0;
// Check for overScroll: 檢查過度滾動:
final double overScroll = physics.applyBoundaryConditions(this, newPixels);
final double actualNewPixels = newPixels - overScroll;
if (actualNewPixels != oldPixels) {
forcePixels(actualNewPixels);
didUpdateScrollPositionBy(actualNewPixels - oldPixels);
}
return overScroll;
}
}
滑動協(xié)調(diào)器 ShopScrollCoordinator
class ShopScrollCoordinator {
/// 頁面主滑動組件標(biāo)識
final String pageLabel = "page";
/// 獲取主頁面滑動控制器
ShopScrollController pageScrollController([double initialOffset = 0.0]) {
assert(initialOffset != null, initialOffset >= 0.0);
_pageInitialOffset = initialOffset;
_pageScrollController = ShopScrollController(this,
debugLabel: pageLabel, initialScrollOffset: initialOffset);
return _pageScrollController;
}
/// 創(chuàng)建并獲取一個子滑動控制器
ShopScrollController newChildScrollController([String debugLabel]) =>
ShopScrollController(this, debugLabel: debugLabel);
/// 子部件滑動數(shù)據(jù)協(xié)調(diào)
/// [delta]滑動距離
/// [userScrollDirection]用戶滑動方向
/// [position]被滑動的子部件的位置信息
void applyUserOffset(double delta,
[ScrollDirection userScrollDirection, ShopScrollPosition position]) {
if (userScrollDirection == ScrollDirection.reverse) {
/// 當(dāng)用戶滑動方向是向上滑動
updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
final innerDelta = _pageScrollPosition.applyClampedDragUpdate(delta);
if (innerDelta != 0.0) {
updateUserScrollDirection(position, userScrollDirection);
position.applyFullDragUpdate(innerDelta);
}
} else {
/// 當(dāng)用戶滑動方向是向下滑動
updateUserScrollDirection(position, userScrollDirection);
final outerDelta = position.applyClampedDragUpdate(delta);
if (outerDelta != 0.0) {
updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
_pageScrollPosition.applyFullDragUpdate(outerDelta);
}
}
}
}
現(xiàn)在,我們在_ShopPageState里添加代碼:
class _ShopPageState extends State<ShopPage>{
// 頁面滑動協(xié)調(diào)器
ShopScrollCoordinator _shopCoordinator;
// 頁面主滑動部件控制器
ShopScrollController _pageScrollController;
// 頁面子滑動部件控制器
ShopScrollController _childScrollController;
/// build 方法中的CustomScrollView和ListView 記得加上控制器!?。?!
@override
void initState() {
super.initState();
_shopCoordinator = ShopScrollCoordinator();
_pageScrollController = _shopCoordinator.pageScrollController();
_childScrollController = _shopCoordinator.newChildScrollController();
}
@override
void dispose() {
_pageScrollController?.dispose();
_childScrollController?.dispose();
super.dispose();
}
}
這個時候,基本實(shí)現(xiàn)了實(shí)現(xiàn)子部件上下滑動關(guān)聯(lián)主部件。效果如圖:

實(shí)現(xiàn)美團(tuán)外賣 點(diǎn)菜 頁面的Body結(jié)構(gòu)
修改_ShopPageState中SliverFillRemaining中內(nèi)容:
/// 注意添加一個新的控制器?。?
SliverFillRemaining(
child: Row(
children: <Widget>[
Expanded(
child: ListView.builder(
controller: _childScrollController,
padding: EdgeInsets.all(0),
physics: AlwaysScrollableScrollPhysics(),
shrinkWrap: true,
itemExtent: 50,
itemCount: 30,
itemBuilder: (context, index) => Container(
padding: EdgeInsets.symmetric(horizontal: 1),
child: Material(
elevation: 4.0,
borderRadius: BorderRadius.circular(5.0),
color: index % 2 == 0
? Colors.cyan
: Colors.deepOrange,
child: Center(child: Text(index.toString())),
)))),
Expanded(
flex: 4,
child: ListView.builder(
controller: _childScrollController1,
padding: EdgeInsets.all(0),
physics: AlwaysScrollableScrollPhysics(),
shrinkWrap: true,
itemExtent: 150,
itemCount: 30,
itemBuilder: (context, index) => Container(
padding: EdgeInsets.symmetric(horizontal: 1),
child: Material(
elevation: 4.0,
borderRadius: BorderRadius.circular(5.0),
color: index % 2 == 0
? Colors.cyan
: Colors.deepOrange,
child: Center(child: Text(index.toString())),
))))
],
))

看來還有些問題,什么問題呢?當(dāng)我只上滑右邊的子部件,當(dāng)
SliverAppBar的最小化時,我們可以看到左邊的子部件的第一個居然不是0。如圖:
跟前面的NestedScrollView中的問題一樣。那我們怎么解決呢?改唄!靈感來自于,F(xiàn)lutter Candies 一桶天下
協(xié)調(diào)器添加方法:
/// 獲取body前吸頂組件高度
double Function() pinnedHeaderSliverHeightBuilder;
bool applyContentDimensions(double minScrollExtent, double maxScrollExtent,
ShopScrollPosition position) {
if (pinnedHeaderSliverHeightBuilder != null) {
maxScrollExtent = maxScrollExtent - pinnedHeaderSliverHeightBuilder();
maxScrollExtent = math.max(0.0, maxScrollExtent);
}
return position.applyContentDimensions(
minScrollExtent, maxScrollExtent, true);
}
修改ShopScrollPosition的applyContentDimensions方法:
@override
bool applyContentDimensions(double minScrollExtent, double maxScrollExtent,
[bool fromCoordinator = false]) {
if (debugLabel == coordinator.pageLabel && !fromCoordinator)
return coordinator.applyContentDimensions(
minScrollExtent, maxScrollExtent, this);
return super.applyContentDimensions(minScrollExtent, maxScrollExtent);
}
這個時候,我們只需要在頁面的初始化協(xié)調(diào)器后,給協(xié)調(diào)器賦值一個返回body之前的所有鎖頂組件折疊后的高度之和的函數(shù)就可以了。
實(shí)現(xiàn)美團(tuán)外賣 店鋪頁面 頭部全屏化展開顯示店鋪信息效果
目標(biāo)如圖:

為什么說是全屏化,這個相信不需要我多講,展開的卡片周圍的灰色就是個padding而已。
用過SliverAppBar的人基本上都能想到,將它的expandedHeight設(shè)置成屏幕高度就可以實(shí)現(xiàn)頭部在展開的時候填充滿整個屏幕。但是,頁面中SliverAppBar默認(rèn)并不是完全展開狀態(tài),當(dāng)然也不是完全收縮狀態(tài),完全收縮狀態(tài)的話,這玩意兒就只剩個AppBar在頂部了。那么我們應(yīng)該怎么讓它默認(rèn)顯示成類似美團(tuán)那樣的呢?
還記得我們的ScrollController的構(gòu)造函數(shù)有個名稱為initialScrollOffset可傳參數(shù)吧,嘿嘿,只要我們把頁面主滑動部件的控制器設(shè)置了initialScrollOffset,頁面豈不是就會默認(rèn)定在initialScrollOffset對應(yīng)的位置。
好的,默認(rèn)位置可以了??墒?,從動圖可以看到,當(dāng)我們下拉部件,使默認(rèn)位置 < 主部件已下滑距離 < 最大展開高度并松開手指時,SliverAppBar會繼續(xù)展開至最大展開高度。那么我們肯定要捕捉手指離開屏幕事件。這個時候呢,我們可以使用Listener組件包裹CustomScrollView,然后在Listener的onPointerUp中獲取手指離開屏幕事件。好的,思路有了。我們來看看怎么實(shí)現(xiàn)吧:
協(xié)調(diào)器外部添加枚舉:
enum PageExpandState { NotExpand, Expanding, Expanded }
協(xié)調(diào)器添加代碼:
/// 主頁面滑動部件默認(rèn)位置
double _pageInitialOffset;
/// 獲取主頁面滑動控制器
ShopScrollController pageScrollController([double initialOffset = 0.0]) {
assert(initialOffset != null, initialOffset >= 0.0);
_pageInitialOffset = initialOffset;
_pageScrollController = ShopScrollController(this,
debugLabel: pageLabel, initialScrollOffset: initialOffset);
return _pageScrollController;
}
/// 當(dāng)默認(rèn)位置不為0時,主部件已下拉距離超過默認(rèn)位置,但超過的距離不大于該值時,
/// 若手指離開屏幕,主部件頭部會回彈至默認(rèn)位置
double _scrollRedundancy = 80;
/// 當(dāng)前頁面Header最大程度展開狀態(tài)
PageExpandState pageExpand = PageExpandState.NotExpand;
/// 當(dāng)手指離開屏幕
void onPointerUp(PointerUpEvent event) {
final double _pagePixels = _pageScrollPosition.pixels;
if (0.0 < _pagePixels && _pagePixels < _pageInitialOffset) {
if (pageExpand == PageExpand.NotExpand &&
_pageInitialOffset - _pagePixels > _scrollRedundancy) {
_pageScrollPosition
.animateTo(0.0,
duration: const Duration(milliseconds: 400), curve: Curves.ease)
.then((value) => pageExpand = PageExpand.Expanded);
} else {
pageExpand = PageExpand.Expanding;
_pageScrollPosition
.animateTo(_pageInitialOffset,
duration: const Duration(milliseconds: 400), curve: Curves.ease)
.then((value) => pageExpand = PageExpand.NotExpand);
}
}
}
這個時候,我們把協(xié)調(diào)器的onPointerUp方法傳給Listener的onPointerUp,我們基本實(shí)現(xiàn)了想要的效果。
But,經(jīng)過測試,其實(shí)它還有個小問題,有時候手指松開它并不會按照我們想象的那樣自動展開或者回到默認(rèn)位置。問題是什么呢?我們知道,手指滑動列表然后離開屏幕時,ScrollPosition的goBallistic方法會被調(diào)用,所以onPointerUp剛被調(diào)用立馬goBallistic也被調(diào)用,當(dāng)goBallistic傳入的速度絕對值很小的時候,那么列表的模擬滑動距離就很小很小,甚至為0.0。那么結(jié)果是怎么樣的,自然而然出現(xiàn)在腦袋中了吧。
我們還需要繼續(xù)修改一下ShopScrollPosition的goBallistic方法:
@override
void goBallistic(double velocity, [bool fromCoordinator = false]) {
if (debugLabel != coordinator.pageLabel) {
if (velocity > 0.0) coordinator.goBallistic(velocity);
} else {
if (fromCoordinator && velocity <= 0.0) return;
if (coordinator.pageExpand == PageExpandState.Expanding) return;
}
assert(pixels != null);
final Simulation simulation =
physics.createBallisticSimulation(this, velocity);
if (simulation != null) {
beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
} else {
goIdle();
}
}
記得頁面initState中,初始化_pageScrollController的時候,記得傳入默認(rèn)位置的值。
此時需要注意一下,默認(rèn)位置的值并不是頁面在默認(rèn)狀態(tài)下SliverAppBar底部在距屏幕頂部的距離,而是屏幕高度減去其底部距屏幕頂部的距離,即initialOffset = screenHeight - x,而這個x我們根據(jù)設(shè)計或者自己的感覺來設(shè)置便是。這里我取200。
來來來,我們看看效果怎么樣??!

文章項目案例 github鏈接 flutter_meituan_shop