我这边是把放活动的地方放在了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