前言
一直覺得高德地圖的首頁Drawer滑動起來很漂亮,還有一些科技感,之前用android實現了一遍,趁著最近不忙再用Flutter實現一遍。
示意圖
為了方便區分佈局結構,我使用了不同的顏色
Drawer高度狀態
可以看到drawer 高度有三種情況:
最大高度
距離頂部有一小段空間,這裡空間高度定位70,
drawer的高度為:螢幕高度-70
中等高度
這裡我們將drawer的顯示高度定位300
最小高度
這裡drawer的顯示高度定位150
Drawer的ui 結構
可以看到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,///手指離屏時會呼叫此方法
複製程式碼
dragStart
當手指觸控螢幕時,我們需要記錄下點選位置:
Offset lastPos;
void verticalDragStart(DragStartDetails details){
lastPos = details.globalPosition;
}
複製程式碼
dragUpdate
之後在使用者滑動時,我們重新整理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(() {
});
}
複製程式碼
dragEnd
這裡我們什麼都不需要做,程式碼如下:
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;
}
複製程式碼
升級 dragStart
現在我們開始對原有的方法升級
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;
}
}
複製程式碼
升級 dragUpdate
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(() {
});
}
複製程式碼
升級dragEnd
在使用者手指離開螢幕時,我們就要進行處理了,即: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){
///在1、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){
///在1、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的滑動功能。
多功能widget 顯隱效果
繼續觀察drawer內部的widget,我們可以看到在top1和top2之間滾動時,內部的多功能區域也會進行相應的縮排和伸出,接下來我們實現這個。
UI佈局
因為我們只需要移動擴充套件區域,就可以實現多功能區的滑出/收起 效果,所以我們可以用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吧。 :)
DEMO
推薦
Bedrock——基於MVVM+Provider的Flutter快速開發框架