我這邊是把放活動的地方放在了TabBar
上方。至於爲何,哈哈,我怕麻煩,由於美團外賣的放活動的組件和下方商品的組件一併點菜
、評價
、商家
頁面的切換而消失,可是這玩意兒又隨商品頁面的上滑而消失,算上主滑動組件,咱們得作讓從商品列表組件上的滑動穿透兩級,實在是麻煩。因此我便把活動的組件放在了TabBar
上方。git
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
來實現的。github
首先咱們寫一個跟NestedScrollView
結構相似的界面ShopPage
出來,關鍵代碼以下:app
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的狀態、滑動的方向、滑動的距離、滑動的速度
等進行協調它們怎麼響應。ide
至於這個協調器怎麼寫,咱們先不着急。咱們應該搞清楚 滑動組件原理,推薦文章:函數
看了這幾個文章,結合咱們的使用場景,咱們須要明白:flex
ScrollerPosition
中的applyUserOffset
方法會獲得滑動矢量;ScrollerPosition
中的goBallistic
方法會獲得手指離開屏幕前滑動速度;簡單來講,咱們須要修改 ScrollerPosition
, ScrollerController
。修改ScrollerPosition
是爲了把手指滑動距離
或手指離開屏幕前滑動速度
傳遞給協調器協調處理。修改ScrollerController
是爲了保證滑動控制器在建立ScrollerPosition
建立的是咱們修改事後的ScrollerPosition
。那麼,開始吧!ui
首先,假設咱們的協調器類名爲ShopScrollCoordinator
。
咱們去複製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,
);
}
///其餘的代碼不要動
}
複製代碼
原版的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;
}
}
複製代碼
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();
}
}
複製代碼
這個時候,基本實現了實現子部件上下滑動關聯主部件。效果如圖:
修改_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