首先,我們看看目標和實現效果
我這邊是把放活動的地方放在了TabBar
上方。至於為什麼,哈哈,我怕麻煩,因為美團外賣的放活動的元件和下方商品的元件一併點菜
、評價
、商家
頁面的切換而消失,但是這玩意兒又隨商品頁面的上滑而消失,算上主滑動元件,我們得做讓從商品列表元件上的滑動穿透兩級,實在是麻煩。所以我便把活動的元件放在了TabBar
上方。
然後我們來分析一下頁面結構
看了前面的動態圖片,我們知道,TabBar
下方的內容(即結構圖中的Body
部分)隨頁面上滑而延伸,內部也包括了滑動元件。看到這種結構,我們自然很容易想到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
的背景設定為透明。當頁面上滑的時候,問題出現了,Body部分穿過了SliverAppBar
和狀態列
下方,到達了螢幕頂部。這樣的話,做出來的效果肯定不是我們想要的。另外,由於NestedScrollView
內部裡面只有一個ScrollController
(下方程式碼中的innerController
),Body
裡面的所有列表的ScrollPosition
都將會attach
到這個ScrollController
上,那麼就又有問題了,我們的商品
頁面裡面有兩個列表,如果共用一個控制器,那麼ScrollPosition
也使用的同一個,這可不行啊,畢竟列表都不一樣,所以因為NestedScrollView
內部裡面只有一個ScrollController
這一點,就決定了我們不能憑藉NestedScrollView
來實現這個效果。但是,NestedScrollView
對我們也不是沒有用,它可是為我們提供了關鍵思路。
為什麼說NestedScrollView
依然對我們有用呢?因為它的特性呀,Body
部分會隨頁面上滑而延伸,Body
部分的底部始終在螢幕的底部。那麼這個Body
部分的高度是怎麼來的?我們去看看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
底部之間的關鍵。好的,知道了這傢伙的存在,我們可以試試自己來做一個跟NestedScrollView
有些類似的效果了。我選擇了最外層滑動元件CustomScrollView
,嘿嘿,NestedScrollView
也是繼承至CustomScrollView
來實現的。
實現一個 NestedScrollView 類似的效果
首先我們寫一個跟NestedScrollView
結構類似的介面ShopPage
出來,關鍵程式碼如下:
class _ShopPageState extends State<ShopPage>{
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
controller: _pageScrollController,
physics: ClampingScrollPhysics(),
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: ClampingScrollPhysics(),
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
伸縮。我們應該怎麼實現呢?首先想想我們要的效果:
- 向上滑動
ListView
時,如果SliverAppBar
是展開狀態,應該先讓SliverAppBar
收縮,當SliverAppBar
不能收縮時,ListView
才會滾動。 - 向下滑動
ListView
時,當ListView
已經滑動到第一個不能再滑動時,SliverAppBar
應該展開,直到SliverAppBar
完全展開。
SliverAppBar
應不應該響應,響應的話是展開還是收縮。我們肯定需要根據滑動方向
和CustomScrollView與ListView已滑動距離
來判斷。所以我們需要一個工具來根據滑動事件是誰發起的、CustomScrollView與ListView的狀態、滑動的方向、滑動的距離、滑動的速度
等進行協調它們怎麼響應。
至於這個協調器怎麼寫,我們先不著急。我們應該搞清楚 滑動元件原理,推薦文章:
看了這幾個文章,結合我們的使用場景,我們需要明白:
- 當手指在螢幕上滑動時,
ScrollerPosition
中的applyUserOffset
方法會得到滑動向量; - 當手指離開螢幕時,
ScrollerPosition
中的goBallistic
方法會得到手指離開螢幕前滑動速度; - 至始自終,主滑動元件上發起的滑動事件,對子滑動部件無干擾,那麼我們在協調時,只需要把子部件的事件傳給協調器分析、協調。
簡單來說,我們需要修改 ScrollerPosition
, ScrollerController
。修改ScrollerPosition
是為了把手指滑動距離
或手指離開螢幕前滑動速度
傳遞給協調器協調處理。修改ScrollerController
是為了保證滑動控制器在建立ScrollerPosition
建立的是我們修改過後的ScrollerPosition
。那麼,開始吧!
實現子部件上下滑動關聯主部件
首先,假設我們的協調器類名為ShopScrollCoordinator
。
滑動控制器 ShopScrollerController
我們去複製ScrollerController
的原始碼,然後為了方便區分,我們把類名改為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
建立的ScrollPosition
是 ScrollPositionWithSingleContext
。
我們去複製ScrollPositionWithSingleContext
的原始碼,然後為了方便區分,我們把類名改為ShopScrollPosition
。前面說了,我們主要是需要修改applyUserOffset
,goBallistic
兩個方法。
class ShopScrollPosition extends ScrollPosition
implements ScrollActivityDelegate {
final ShopScrollCoordinator coordinator; // 協調器
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);
}
/// 當手指滑動時,該方法會獲取到滑動距離
/// [delta]滑動距離,正增量表示下滑,負增量向上滑
/// 我們需要把子部件的 滑動資料 交給協調器處理,主部件無干擾
@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));
}
/// 以特定的速度開始一個物理驅動的模擬,該模擬確定[pixels]位置。
/// 此方法遵從[ScrollPhysics.createBallisticSimulation],該方法通常在當前位置超出
/// 範圍時提供滑動模擬,而在當前位置超出範圍但具有非零速度時提供摩擦模擬。
/// 速度應以每秒邏輯畫素為單位。
/// [velocity]手指離開螢幕前滑動速度,正表示下滑,負向上滑
@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;
}
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;
}
}
複製程式碼
滑動協調器 ShopScrollCoordinator
class ShopScrollCoordinator {
/// 頁面主滑動元件標識
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;
}
/// 建立並獲取一個子滑動控制器
ShopScrollController newChildScrollController([String debugLabel]) =>
ShopScrollController(this, debugLabel: debugLabel);
/// 子部件滑動資料協調
/// [delta]滑動距離
/// [userScrollDirection]使用者滑動方向
/// [position]被滑動的子部件的位置資訊
void applyUserOffset(double delta,
[ScrollDirection userScrollDirection, ShopScrollPosition position]) {
if (userScrollDirection == ScrollDirection.reverse) {
/// 當使用者滑動方向是向上滑動
updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
final innerDelta = _pageScrollPosition.applyClampedDragUpdate(delta);
if (innerDelta != 0.0) {
updateUserScrollDirection(position, userScrollDirection);
position.applyFullDragUpdate(innerDelta);
}
} else {
/// 當使用者滑動方向是向下滑動
updateUserScrollDirection(position, userScrollDirection);
final outerDelta = position.applyClampedDragUpdate(delta);
if (outerDelta != 0.0) {
updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
_pageScrollPosition.applyFullDragUpdate(outerDelta);
}
}
}
}
複製程式碼
現在,我們在_ShopPageState
裡新增程式碼:
class _ShopPageState extends State<ShopPage>{
// 頁面滑動協調器
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();
}
}
複製程式碼
這個時候,基本實現了實現子部件上下滑動關聯主部件。效果如圖:
實現美團外賣 點菜 頁面的Body結構
修改_ShopPageState
中SliverFillRemaining
中內容:
/// 注意新增一個新的控制器!!
SliverFillRemaining(
child: Row(
children: <Widget>[
Expanded(
child: ListView.builder(
controller: _childScrollController,
padding: EdgeInsets.all(0),
physics: ClampingScrollPhysics(),
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: ClampingScrollPhysics(),
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())),
))))
],
))
複製程式碼
看效果
看來還有些問題,什麼問題呢?當我只上滑右邊的子部件,當SliverAppBar
的最小化時,我們可以看到左邊的子部件的第一個居然不是0。如圖:
跟前面的NestedScrollView
中的問題一樣。那我們怎麼解決呢?改唄!靈感來自於,Flutter Candies 一桶天下
協調器新增方法:
/// 獲取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);
}
複製程式碼
這個時候,我們只需要在頁面的初始化協調器後,給協調器賦值一個返回body之前的所有鎖頂元件摺疊後的高度之和的函式就可以了。
實現美團外賣 店鋪頁面 頭部全屏化展開顯示店鋪資訊效果
目標如圖:
為什麼說是全屏化,這個相信不需要我多講,展開的卡片周圍的灰色就是個padding
而已。
用過SliverAppBar
的人基本上都能想到,將它的expandedHeight
設定成螢幕高度就可以實現頭部在展開的時候填充滿整個螢幕。但是,頁面中SliverAppBar
預設並不是完全展開狀態,當然也不是完全收縮狀態,完全收縮狀態的話,這玩意兒就只剩個AppBar在頂部了。那麼我們應該怎麼讓它預設顯示成類似美團那樣的呢?
還記得我們的ScrollController
的建構函式有個名稱為initialScrollOffset
可傳引數吧,嘿嘿,只要我們把頁面主滑動部件的控制器設定了initialScrollOffset
,頁面豈不是就會預設定在initialScrollOffset
對應的位置。
好的,預設位置可以了。可是,從動圖可以看到,當我們下拉部件,使預設位置 < 主部件已下滑距離 < 最大展開高度
並鬆開手指時,SliverAppBar
會繼續展開至最大展開高度
。那麼我們肯定要捕捉手指離開螢幕事件。這個時候呢,我們可以使用Listener
元件包裹CustomScrollView
,然後在Listener
的onPointerUp
中獲取手指離開螢幕事件。好的,思路有了。我們來看看怎麼實現吧:
協調器外部新增列舉:
enum PageExpandState { NotExpand, Expanding, Expanded }
複製程式碼
協調器新增程式碼:
/// 主頁面滑動部件預設位置
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;
}
/// 當預設位置不為0時,主部件已下拉距離超過預設位置,但超過的距離不大於該值時,
/// 若手指離開螢幕,主部件頭部會回彈至預設位置
double _scrollRedundancy = 80;
/// 當前頁面Header最大程度展開狀態
PageExpandState pageExpand = PageExpandState.NotExpand;
/// 當手指離開螢幕
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);
}
}
}
複製程式碼
這個時候,我們把協調器的onPointerUp
方法傳給Listener
的onPointerUp
,我們基本實現了想要的效果。
But,經過測試,其實它還有個小問題,有時候手指鬆開它並不會按照我們想象的那樣自動展開或者回到預設位置。問題是什麼呢?我們知道,手指滑動列表然後離開螢幕時,ScrollPosition
的goBallistic
方法會被呼叫,所以onPointerUp
剛被呼叫立馬goBallistic
也被呼叫,當goBallistic
傳入的速度絕對值很小的時候,那麼列表的模擬滑動距離就很小很小,甚至為0.0。那麼結果是怎麼樣的,自然而然出現在腦袋中了吧。
我們還需要繼續修改一下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
的時候,記得傳入預設位置的值。
此時需要注意一下,預設位置的值並不是頁面在預設狀態下SliverAppBar
底部在距螢幕頂部的距離,而是螢幕高度減去其底部距螢幕頂部的距離,即initialOffset = screenHeight - x
,而這個x
我們根據設計或者自己的感覺來設定便是。這裡我取200。
來來來,我們看看效果怎麼樣!!
文章專案案例 github連結 flutter_meituan_shop