一直以爲高德地圖的首頁Drawer滑動起來很漂亮,還有一些科技感,以前用android實現了一遍,趁着最近不忙再用Flutter實現一遍。android
爲了方便區分佈局結構,我使用了不一樣的顏色git
能夠看到drawer 高度有三種狀況:github
距離頂部有一小段空間,這裏空間高度定位70,bash
drawer的高度爲:屏幕高度-70框架
這裏咱們將drawer的顯示高度定位300async
這裏drawer的顯示高度定位150ide
能夠看到drawer內部的ui分爲三塊:函數
搜索區域、多功能區域、擴展區域
複製代碼
同時drawer在最大高度和中等高度之間滾動時,多功能區域須要縮進/展開 到 擴展區域源碼分析
由於窗口最底層須要顯示地圖,同時drawer要顯示不一樣的高度,因此這裏我採用stack做爲跟佈局:佈局
size由mediaQuery.of(context)得到
複製代碼
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
child: Container(
color: Colors.greenAccent,
width: size.width,height: size.height,
child: Stack(
children: <Widget>[
Positioned(
top: initPositionTop,
.......省去Drawer部分代碼
)
],
),
),
);
複製代碼
咱們經過positioned包裹drawer,而後經過top來控制drawer上下移動的高度,爲了捕獲觸摸事件,咱們須要用GestureDetector對咱們的drawer進行包裹,代碼:
Positioned(
top: initPositionTop,
child: GestureDetector(
onVerticalDragStart: verticalDragStart,
onVerticalDragUpdate: verticalDragUpdate,
onVerticalDragEnd: verticalDragEnd,
///Drawer
child: Container(
width: size.width,height: drawerHeight,
color: Colors.white,
///多功能區域須要實現縮進和站看,因此這裏使用stack做爲drawer的內部根佈局
child: Stack(
children: <Widget>[
///搜索區域
Container(
alignment: Alignment.center,
color: Colors.pink,
width: size.width,height: searchHeight - minHeight,
child: Text('我是搜索'),
),
///多功能區域
Positioned(
top: searchHeight - minHeight,
child: Container(
alignment: Alignment.center,
color: Colors.white,
width: size.width,height: rowH * 3+20,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
normalRow(),
normalRow(),
Container(
color: Colors.grey[300],
width: size.width,height: rowH,
alignment: Alignment.topCenter,
child: Text('常去的地方',style: TextStyle(fontSize: 18,color: Colors.black),),
)
],
),
),
),
///擴展區域
Positioned(
top: expandPosTop + topArea,
child: Container(
color: Colors.lightGreen,
alignment: Alignment.topCenter,
width: size.width,height: drawerHeight - searchHeight -rowH,///這裏須要在滾動時向下滑動
child: Text('我是擴展區域'),
),
),
],
),
),
),
)
複製代碼
至此整個UI佈局就搞定了,接下來處理手勢滑動。
首先咱們只須要處理垂直滑動,所以在回調中,咱們實現這三個方法:
child: GestureDetector(
onVerticalDragStart: verticalDragStart, ///第一次觸摸屏幕時觸發
onVerticalDragUpdate: verticalDragUpdate,///滑動時會持續調用此方法
onVerticalDragEnd: verticalDragEnd,///手指離屏時會調用此方法
複製代碼
當手指觸摸屏幕時,咱們須要記錄下點擊位置:
Offset lastPos;
void verticalDragStart(DragStartDetails details){
lastPos = details.globalPosition;
}
複製代碼
以後在用戶滑動時,咱們刷新drawer的position的top值(即initPositionTop),以此來達到drawer的滑動效果。
若是隻是簡單的滑動,咱們能夠直接將initPositionTop加上滑動差值便可,可是根據經驗判斷,後面確定會須要滑動方向,因此我在這裏順便把滑動的方向也記錄下來,這個能夠根據滑動差值的正負來判斷:
enum SlideDirection{
Up,
Down
}
複製代碼
void verticalDragUpdate(DragUpdateDetails details){
double dis = details.globalPosition.dy - lastPos.dy;
if(dis<0){
direction = SlideDirection.Up;
}else{
direction = SlideDirection.Down;
}
if(direction == SlideDirection.Up){
if(initPositionTop <= top1+cacheDy) return;
}else if(direction == SlideDirection.Down){
if(initPositionTop >= top3-cacheDy) return;
}
initPositionTop += dis;
///處理完一次後,記下當前的位置
lastPos = details.globalPosition;
///這裏個方法暫時不用管
refreshExpandWidgetTop();
setState(() {
});
}
複製代碼
這裏咱們什麼都不須要作,代碼以下:
void verticalDragEnd(DragEndDetails details){
}
複製代碼
這時咱們運行發現,drawer能夠跟着手指的滑動表現收起/展開的效果,可是咱們的手指離屏後,drawer也就停在那了(原始版抽屜)。
參見高德,能夠看到抽屜始終會停留在三級狀態中的一級,若是手指滑動超出界限/未到界限,抽屜會自動滾動/滾回到最近的等級高度,如今咱們要進行升級了。
首先咱們要記錄一下三個高度對應的position的top值(drawer的實時top值之後就叫initPositionTop了):
///stack 中 根container 的position 的top 值的三種狀況
double top1;// DrawerLvl lvl 1
double top2;// DrawerLvl lvl 2
double top3;// DrawerLvl lvl 3
double initPositionTop;
///初始化
top1 = size.height - drawerHeight;
top2 = size.height - searchHeight;
top3 = size.height - minHeight;
///頁面最初顯示的是 top2等級
initPositionTop = top2;
複製代碼
而後咱們須要記錄一下drawer的狀態:
enum DrawerLvl{
LVL1,
LVL2,
LVL3
}
///抽屜層級
DrawerLvl drawerLvl = DrawerLvl.LVL2;
///滑動方向
SlideDirection direction;
複製代碼
分別對應top1,top2,top3
當咱們滑動時,若是從top1滑向top2,可是未到top2的高度,就鬆手了,這時咱們須要完成剩下的操做,這就用到了
AnimationController
Animation
複製代碼
animationController = AnimationController(vsync: this,duration: Duration(milliseconds: 300));
複製代碼
具體應該滑回top1,仍是滑向top2呢?這裏咱們須要定兩個閾值:
///層級之間的閾值
double threshold1To2;
double threshold2To3;
///構造函數
DrawerDemoState(this.size){
drawerHeight = size.height-paddingTop;
threshold1To2 = size.height/3;
threshold2To3 = size.height - 250;
}
複製代碼
如今咱們開始對原有的方法升級
void verticalDragStart(DragStartDetails details){
///肯定drawer 初始狀態
markDrawerLvl();
///將原有的動畫置空
animation = null;
///將控制器中止和復位
if(animationController.isAnimating){
animationController.stop();
}
animationController.reset();
lastPos = details.globalPosition;
log('start', '$initPositionTop');
}
複製代碼
當用戶觸摸時,咱們先要肯定drawer的初始狀態:
markDrawerLvl(){
double l1 = (top1-initPositionTop).abs();
double l2 = (top2-initPositionTop).abs();
double l3 = (top3-initPositionTop).abs();
if(l1 == (math.min(l1, math.min(l2, l3)))){
drawerLvl = DrawerLvl.LVL1;
}else if(l2 == (math.min(l1, math.min(l2, l3)))){
drawerLvl = DrawerLvl.LVL2;
}else {
drawerLvl = DrawerLvl.LVL3;
}
}
複製代碼
void verticalDragUpdate(DragUpdateDetails details){
double dis = details.globalPosition.dy - lastPos.dy;
if(dis<0){
direction = SlideDirection.Up;
}else{
direction = SlideDirection.Down;
}
///cacheDy 避免滑動過快溢出範圍致使的判斷失效
if(direction == SlideDirection.Up){
///避免drawer滑出屏幕
if(initPositionTop <= top1+cacheDy) return;
}else if(direction == SlideDirection.Down){
if(initPositionTop >= top3-cacheDy) return;
}
initPositionTop += dis;
lastPos = details.globalPosition;
///暫時不用管
refreshExpandWidgetTop();
setState(() {
});
}
複製代碼
在用戶手指離開屏幕時,咱們就要進行處理了,即:drawer是繼續滾動,仍是復位。
void verticalDragEnd(DragEndDetails details){
adjustPositionTop(details);
}
複製代碼
這個方法較長,我將說明寫在註釋裏
void adjustPositionTop(DragEndDetails details){
switch(direction){
case SlideDirection.Up:
if(details.velocity.pixelsPerSecond.dy.abs() > thresholdV){
///用戶fling速度超過閾值後,直接斷定爲滑向下一級別
switch(drawerLvl){
case DrawerLvl.LVL1:
///處於頂部上滑時,不須要作處理
// TODO: Handle this case.
break;
case DrawerLvl.LVL2:
slideTo(begin: initPositionTop,end: top1);
break;
case DrawerLvl.LVL3:
slideTo(begin: initPositionTop,end: top2);
break;
}
}else{
///未超過閾值的話,咱們則進行復位或者繼續滑動
if(initPositionTop >= top1 && initPositionTop <= top2){
///在一、2級之間
這裏根據手指離屏位置,進行復位或者滑向下一等級高度的處理
if(initPositionTop <= threshold1To2){
///小於二分之一屏幕高度 滾向top1
slideTo(begin:initPositionTop, end:top1);
}else{
///滑向top2
slideTo(begin: initPositionTop,end: top2);
}
}else if(initPositionTop >= top2 && initPositionTop <= top3){
///2-3之間
if(initPositionTop <= threshold2To3){
///滑向2
slideTo(begin: initPositionTop,end: top2);
}else{
///滑向3
slideTo(begin: initPositionTop,end: top3);
}
}
}
break;
case SlideDirection.Down:
///原理同上
if(details.velocity.pixelsPerSecond.dy.abs() > thresholdV){
switch(drawerLvl){
case DrawerLvl.LVL1:
slideTo(begin: initPositionTop,end: top2);
break;
case DrawerLvl.LVL2:
slideTo(begin: initPositionTop,end: top3);
break;
case DrawerLvl.LVL3:
//todo nothing
break;
}
}else{
if(initPositionTop >= top1 && initPositionTop <= top2){
///在一、2級之間
if(initPositionTop <= threshold1To2){
///小於二分之一屏幕高度 滾向top1
slideTo(begin: initPositionTop,end:top1);
}else{
///滑向top2
slideTo(begin: initPositionTop,end: top2);
}
}else if(initPositionTop >= top2 && initPositionTop <= top3){
///2-3之間
if(initPositionTop <= threshold2To3){
///滑向2
slideTo(begin: initPositionTop,end: top2);
}else{
///滑向3
slideTo(begin: initPositionTop,end: top3);
}
}
}
break;
}
}
複製代碼
在補全滑動這裏,咱們交給animationController來處理:
///begin基本是手指離屏的位置,end則是目標等級的top值
slideTo({double begin,double end})async{
animation = Tween<double>(begin: begin,end:end ).animate(animationController);
await animationController.forward();
}
複製代碼
在動畫的listener中,咱們刷新initPositionTop的值:
animationController.addListener(() {
if(animation == null) return;
///暫時不用管
refreshExpandWidgetTop();
setState(() {
initPositionTop = animation.value;
});
});
複製代碼
至此咱們就相對完善的完成了drawer的滑動功能。
繼續觀察drawer內部的widget,咱們能夠看到在top1和top2之間滾動時,內部的多功能區域也會進行相應的縮進和伸出,接下來咱們實現這個。
由於咱們只須要移動擴展區域,就能夠實現多功能區的滑出/收起 效果,因此咱們能夠用stack來完成基本的佈局:
Stack(
children: <Widget>[
///搜索
Container(
alignment: Alignment.center,
color: Colors.pink,
width: size.width,height: searchHeight - minHeight,
child: Text('我是搜索'),
),
///多功能區
Positioned(
top: searchHeight - minHeight,
child: Container(
alignment: Alignment.center,
color: Colors.white,
width: size.width,height: rowH * 3+20,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
normalRow(),
normalRow(),
Container(
color: Colors.grey[300],
width: size.width,height: rowH,
alignment: Alignment.topCenter,
child: Text('常去的地方',style: TextStyle(fontSize: 18,color: Colors.black),),
)
],
),
),
),
///擴展區
Positioned(
top: expandPosTop + topArea,
child: Container(
color: Colors.lightGreen,
alignment: Alignment.topCenter,
width: size.width,height: drawerHeight - searchHeight -rowH,///這裏須要在滾動時向下滑動
child: Text('我是擴展區域'),
),
),
],
),
複製代碼
搜索區和多功能區,只須要調整top,使他們順序排列便可。
而擴展區,咱們須要在頁面初始是遮住一部分多功能區(只漏出一行圓)。
方便起見,將多功能的高度定位 rowH * 3;
複製代碼
那麼擴展區的top初始值就是多功能的top + rowH,這裏咱們給擴展區的top值定義一個變量:
expandPosTop = 多功能區的top + rowH
複製代碼
進而,咱們能夠肯定,expandPosTop的變化範圍是:
咱們給這個變化值定義一個變量:topArea
topArea = [0 - rowH * 2];
複製代碼
最終擴展區的代碼以下:
///擴展區域
Positioned(
top: expandPosTop + topArea,
child: Container(
color: Colors.lightGreen,
alignment: Alignment.topCenter,
width: size.width,height: drawerHeight - searchHeight -rowH,///這裏須要在滾動時向下滑動
child: Text('我是擴展區域'),
),
),
複製代碼
總體UI佈局就完成了,咱們接着實現滾動功能。
咱們在dragUpdate和動畫的listener中見到過這個方法:
refreshExpandWidgetTop();//這裏就是實現對應功能的
複製代碼
這裏我把說明寫在註釋裏,方便閱讀
///刷新 擴展區域的 position top值
///這裏的差值是 rowH * 2
refreshExpandWidgetTop(){
///首先,咱們根據initPositionTop,和top2 - top1 之間的差值,來計算滑動進度
double progress = (initPositionTop-top2).abs() /(top2 - top1).abs();
///判斷是從top1滑向top2 仍是反着
if(drawerLvl == DrawerLvl.LVL2){
///lvl2 滑向 lvl3時 不作處理
if(initPositionTop > top2) return;
///以後咱們根據進度,來刷新topArea的值
///這個值老是會在 0 到 rowh*2 這個範圍內變化,具體由滑動方向來定
topArea = (progress * (rowH*2).clamp(0, rowH*2));
}else if(drawerLvl == DrawerLvl.LVL1){
///lvl2 滑向 lvl3時 不作處理
if(initPositionTop > top2) return;
topArea = (progress) * (rowH*2).clamp(0, rowH*2);
}
}
複製代碼
當咱們在調用上述方法外面刷新時,就會看到多功能區域的收起/伸出的效果了(給加點陰影會更好看),至此咱們整個功能就實現了,若是對你有幫助點歌贊或和star吧。 :)
Bedrock——基於MVVM+Provider的Flutter快速開發框架