作者:吉哈達(dá)
鏈接:https://juejin.im/post/5f179a5b6fb9a07ec07b56cd
前言
一直覺得高德地圖的首頁Drawer滑動(dòng)起來很漂亮,還有一些科技感萎庭,之前用android實(shí)現(xiàn)了一遍告材,趁著最近不忙再用Flutter實(shí)現(xiàn)一遍。
示意圖
為了方便區(qū)分布局結(jié)構(gòu)步鉴,我使用了不同的顏色
Drawer高度狀態(tài)
可以看到drawer 高度有三種情況:
最大高度
距離頂部有一小段空間揪胃,這里空間高度定位70,
drawer的高度為:屏幕高度-70
中等高度
這里我們將drawer的顯示高度定位300
最小高度
這里drawer的顯示高度定位150
Drawer的ui 結(jié)構(gòu)
可以看到drawer內(nèi)部的ui分為三塊:
搜索區(qū)域氛琢、多功能區(qū)域喊递、擴(kuò)展區(qū)域
復(fù)制代碼
同時(shí)drawer在最大高度和中等高度之間滾動(dòng)時(shí),多功能區(qū)域需要縮進(jìn)/展開 到 擴(kuò)展區(qū)域
代碼實(shí)現(xiàn)
基本布局
因?yàn)榇翱谧畹讓有枰@示地圖阳似,同時(shí)drawer要顯示不同的高度骚勘,所以這里我采用stack作為跟布局:
size由mediaQuery.of(context)獲得
復(fù)制代碼
@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部分代碼
)
],
),
),
);
復(fù)制代碼
我們通過positioned包裹drawer,然后通過top來控制drawer上下移動(dòng)的高度,為了捕獲觸摸事件撮奏,我們需要用GestureDetector對我們的drawer進(jìn)行包裹俏讹,代碼:
Positioned(
top: initPositionTop,
child: GestureDetector(
onVerticalDragStart: verticalDragStart,
onVerticalDragUpdate: verticalDragUpdate,
onVerticalDragEnd: verticalDragEnd,
///Drawer
child: Container(
width: size.width,height: drawerHeight,
color: Colors.white,
///多功能區(qū)域需要實(shí)現(xiàn)縮進(jìn)和站看,所以這里使用stack作為drawer的內(nèi)部根布局
child: Stack(
children: <Widget>[
///搜索區(qū)域
Container(
alignment: Alignment.center,
color: Colors.pink,
width: size.width,height: searchHeight - minHeight,
child: Text('我是搜索'),
),
///多功能區(qū)域
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),),
)
],
),
),
),
///擴(kuò)展區(qū)域
Positioned(
top: expandPosTop + topArea,
child: Container(
color: Colors.lightGreen,
alignment: Alignment.topCenter,
width: size.width,height: drawerHeight - searchHeight -rowH,///這里需要在滾動(dòng)時(shí)向下滑動(dòng)
child: Text('我是擴(kuò)展區(qū)域'),
),
),
],
),
),
),
)
復(fù)制代碼
至此整個(gè)UI布局就搞定了畜吊,接下來處理手勢滑動(dòng)泽疆。
手勢處理
首先我們只需要處理垂直滑動(dòng),因此在回調(diào)中玲献,我們實(shí)現(xiàn)這三個(gè)方法:
child: GestureDetector(
onVerticalDragStart: verticalDragStart, ///第一次觸摸屏幕時(shí)觸發(fā)
onVerticalDragUpdate: verticalDragUpdate,///滑動(dòng)時(shí)會(huì)持續(xù)調(diào)用此方法
onVerticalDragEnd: verticalDragEnd,///手指離屏?xí)r會(huì)調(diào)用此方法
復(fù)制代碼
dragStart
當(dāng)手指觸摸屏幕時(shí)殉疼,我們需要記錄下點(diǎn)擊位置:
Offset lastPos;
void verticalDragStart(DragStartDetails details){
lastPos = details.globalPosition;
}
復(fù)制代碼
dragUpdate
之后在用戶滑動(dòng)時(shí)逗嫡,我們刷新drawer的position的top值(即initPositionTop),以此來達(dá)到drawer的滑動(dòng)效果株依。
如果只是簡單的滑動(dòng),我們可以直接將initPositionTop加上滑動(dòng)差值即可延窜,但是根據(jù)經(jīng)驗(yàn)判斷恋腕,后面肯定會(huì)需要滑動(dòng)方向,所以我在這里順便把滑動(dòng)的方向也記錄下來逆瑞,這個(gè)可以根據(jù)滑動(dòng)差值的正負(fù)來判斷:
enum SlideDirection{
Up,
Down
}
復(fù)制代碼
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;
///處理完一次后荠藤,記下當(dāng)前的位置
lastPos = details.globalPosition;
///這里個(gè)方法暫時(shí)不用管
refreshExpandWidgetTop();
setState(() {
});
}
復(fù)制代碼
dragEnd
這里我們什么都不需要做,代碼如下:
void verticalDragEnd(DragEndDetails details){
}
復(fù)制代碼
這時(shí)我們運(yùn)行發(fā)現(xiàn)获高,drawer可以跟著手指的滑動(dòng)表現(xiàn)收起/展開的效果哈肖,但是我們的手指離屏后,drawer也就停在那了(原始版抽屜)念秧。
參見高德淤井,可以看到抽屜始終會(huì)停留在三級狀態(tài)中的一級,如果手指滑動(dòng)超出界限/未到界限摊趾,抽屜會(huì)自動(dòng)滾動(dòng)/滾回到最近的等級高度币狠,現(xiàn)在我們要進(jìn)行升級了。
升級
準(zhǔn)備工作
首先我們要記錄一下三個(gè)高度對應(yīng)的position的top值(drawer的實(shí)時(shí)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;
復(fù)制代碼
然后我們需要記錄一下drawer的狀態(tài):
enum DrawerLvl{
LVL1,
LVL2,
LVL3
}
///抽屜層級
DrawerLvl drawerLvl = DrawerLvl.LVL2;
///滑動(dòng)方向
SlideDirection direction;
復(fù)制代碼
分別對應(yīng)top1砾层,top2漩绵,top3
當(dāng)我們滑動(dòng)時(shí),如果從top1滑向top2肛炮,但是未到top2的高度止吐,就松手了,這時(shí)我們需要完成剩下的操作侨糟,這就用到了
AnimationController
Animation
復(fù)制代碼
animationController = AnimationController(vsync: this,duration: Duration(milliseconds: 300));
復(fù)制代碼
具體應(yīng)該滑回top1碍扔,還是滑向top2呢?這里我們需要定兩個(gè)閾值:
///層級之間的閾值
double threshold1To2;
double threshold2To3;
///構(gòu)造函數(shù)
DrawerDemoState(this.size){
drawerHeight = size.height-paddingTop;
threshold1To2 = size.height/3;
threshold2To3 = size.height - 250;
}
復(fù)制代碼
升級 dragStart
現(xiàn)在我們開始對原有的方法升級
void verticalDragStart(DragStartDetails details){
///確定drawer 初始狀態(tài)
markDrawerLvl();
///將原有的動(dòng)畫置空
animation = null;
///將控制器停止和復(fù)位
if(animationController.isAnimating){
animationController.stop();
}
animationController.reset();
lastPos = details.globalPosition;
log('start', '$initPositionTop');
}
復(fù)制代碼
當(dāng)用戶觸摸時(shí)秕重,我們先要確定drawer的初始狀態(tài):
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;
}
}
復(fù)制代碼
升級 dragUpdate
void verticalDragUpdate(DragUpdateDetails details){
double dis = details.globalPosition.dy - lastPos.dy;
if(dis<0){
direction = SlideDirection.Up;
}else{
direction = SlideDirection.Down;
}
///cacheDy 避免滑動(dòng)過快溢出范圍導(dǎo)致的判斷失效
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;
///暫時(shí)不用管
refreshExpandWidgetTop();
setState(() {
});
}
復(fù)制代碼
升級dragEnd
在用戶手指離開屏幕時(shí)蕴忆,我們就要進(jìn)行處理了,即:drawer是繼續(xù)滾動(dòng)悲幅,還是復(fù)位套鹅。
void verticalDragEnd(DragEndDetails details){
adjustPositionTop(details);
}
復(fù)制代碼
這個(gè)方法較長,我將說明寫在注釋里
void adjustPositionTop(DragEndDetails details){
switch(direction){
case SlideDirection.Up:
if(details.velocity.pixelsPerSecond.dy.abs() > thresholdV){
///用戶fling速度超過閾值后汰具,直接判定為滑向下一級別
switch(drawerLvl){
case DrawerLvl.LVL1:
///處于頂部上滑時(shí)卓鹿,不需要做處理
// TODO: Handle this case.
break;
case DrawerLvl.LVL2:
slideTo(begin: initPositionTop,end: top1);
break;
case DrawerLvl.LVL3:
slideTo(begin: initPositionTop,end: top2);
break;
}
}else{
///未超過閾值的話,我們則進(jìn)行復(fù)位或者繼續(xù)滑動(dòng)
if(initPositionTop >= top1 && initPositionTop <= top2){
///在1留荔、2級之間
這里根據(jù)手指離屏位置吟孙,進(jìn)行復(fù)位或者滑向下一等級高度的處理
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;
}
}
復(fù)制代碼
在補(bǔ)全滑動(dòng)這里,我們交給animationController來處理:
///begin基本是手指離屏的位置杰妓,end則是目標(biāo)等級的top值
slideTo({double begin,double end})async{
animation = Tween<double>(begin: begin,end:end ).animate(animationController);
await animationController.forward();
}
復(fù)制代碼
在動(dòng)畫的listener中藻治,我們刷新initPositionTop的值:
animationController.addListener(() {
if(animation == null) return;
///暫時(shí)不用管
refreshExpandWidgetTop();
setState(() {
initPositionTop = animation.value;
});
});
復(fù)制代碼
至此我們就相對完善的完成了drawer的滑動(dòng)功能。
多功能widget 顯隱效果
繼續(xù)觀察drawer內(nèi)部的widget巷挥,我們可以看到在top1和top2之間滾動(dòng)時(shí)桩卵,內(nèi)部的多功能區(qū)域也會(huì)進(jìn)行相應(yīng)的縮進(jìn)和伸出,接下來我們實(shí)現(xiàn)這個(gè)倍宾。
UI布局
因?yàn)槲覀冎恍枰苿?dòng)擴(kuò)展區(qū)域雏节,就可以實(shí)現(xiàn)多功能區(qū)的滑出/收起 效果,所以我們可以用stack來完成基本的布局:
Stack(
children: <Widget>[
///搜索
Container(
alignment: Alignment.center,
color: Colors.pink,
width: size.width,height: searchHeight - minHeight,
child: Text('我是搜索'),
),
///多功能區(qū)
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),),
)
],
),
),
),
///擴(kuò)展區(qū)
Positioned(
top: expandPosTop + topArea,
child: Container(
color: Colors.lightGreen,
alignment: Alignment.topCenter,
width: size.width,height: drawerHeight - searchHeight -rowH,///這里需要在滾動(dòng)時(shí)向下滑動(dòng)
child: Text('我是擴(kuò)展區(qū)域'),
),
),
],
),
復(fù)制代碼
搜索區(qū)和多功能區(qū)高职,只需要調(diào)整top钩乍,使他們順序排列即可。
而擴(kuò)展區(qū)怔锌,我們需要在頁面初始是遮住一部分多功能區(qū)(只漏出一行圓)寥粹。
方便起見,將多功能的高度定位 rowH * 3;
復(fù)制代碼
那么擴(kuò)展區(qū)的top初始值就是多功能的top + rowH埃元,這里我們給擴(kuò)展區(qū)的top值定義一個(gè)變量:
expandPosTop = 多功能區(qū)的top + rowH
復(fù)制代碼
進(jìn)而排作,我們可以確定,expandPosTop的變化范圍是:
我們給這個(gè)變化值定義一個(gè)變量:topArea
topArea = [0 - rowH * 2]亚情;
復(fù)制代碼
最終擴(kuò)展區(qū)的代碼如下:
///擴(kuò)展區(qū)域
Positioned(
top: expandPosTop + topArea,
child: Container(
color: Colors.lightGreen,
alignment: Alignment.topCenter,
width: size.width,height: drawerHeight - searchHeight -rowH,///這里需要在滾動(dòng)時(shí)向下滑動(dòng)
child: Text('我是擴(kuò)展區(qū)域'),
),
),
復(fù)制代碼
整體UI布局就完成了妄痪,我們接著實(shí)現(xiàn)滾動(dòng)功能。
擴(kuò)展區(qū)滑動(dòng)
我們在dragUpdate和動(dòng)畫的listener中見到過這個(gè)方法:
refreshExpandWidgetTop();//這里就是實(shí)現(xiàn)對應(yīng)功能的
復(fù)制代碼
這里我把說明寫在注釋里楞件,方便閱讀
///刷新 擴(kuò)展區(qū)域的 position top值
///這里的差值是 rowH * 2
refreshExpandWidgetTop(){
///首先衫生,我們根據(jù)initPositionTop,和top2 - top1 之間的差值土浸,來計(jì)算滑動(dòng)進(jìn)度
double progress = (initPositionTop-top2).abs() /(top2 - top1).abs();
///判斷是從top1滑向top2 還是反著
if(drawerLvl == DrawerLvl.LVL2){
///lvl2 滑向 lvl3時(shí) 不做處理
if(initPositionTop > top2) return;
///之后我們根據(jù)進(jìn)度罪针,來刷新topArea的值
///這個(gè)值總是會(huì)在 0 到 rowh*2 這個(gè)范圍內(nèi)變化,具體由滑動(dòng)方向來定
topArea = (progress * (rowH*2).clamp(0, rowH*2));
}else if(drawerLvl == DrawerLvl.LVL1){
///lvl2 滑向 lvl3時(shí) 不做處理
if(initPositionTop > top2) return;
topArea = (progress) * (rowH*2).clamp(0, rowH*2);
}
}
復(fù)制代碼
當(dāng)我們在調(diào)用上述方法外面刷新時(shí)黄伊,就會(huì)看到多功能區(qū)域的收起/伸出的效果了(給加點(diǎn)陰影會(huì)更好看)泪酱,至此我們整個(gè)功能就實(shí)現(xiàn)了,如果對你有幫助點(diǎn)歌贊或和star吧还最。 :)
點(diǎn)關(guān)注墓阀,更多Android開發(fā)技能~~