Flutter自定義View——仿高德三級聯動Drawer

吉哈達發表於2020-07-22

前言

一直覺得高德地圖的首頁Drawer滑動起來很漂亮,還有一些科技感,之前用android實現了一遍,趁著最近不忙再用Flutter實現一遍。

示意圖

為了方便區分佈局結構,我使用了不同的顏色

Flutter自定義View——仿高德三級聯動Drawer

Drawer高度狀態

可以看到drawer 高度有三種情況:

最大高度

距離頂部有一小段空間,這裡空間高度定位70,

drawer的高度為:螢幕高度-70

Flutter自定義View——仿高德三級聯動Drawer

中等高度

這裡我們將drawer的顯示高度定位300

Flutter自定義View——仿高德三級聯動Drawer

最小高度

這裡drawer的顯示高度定位150

Flutter自定義View——仿高德三級聯動Drawer

Drawer的ui 結構

Flutter自定義View——仿高德三級聯動Drawer

可以看到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

Demo

推薦

Bedrock——基於MVVM+Provider的Flutter快速開發框架

Flutter 自定義View——仿同花順自選股列表

Flutter——PageView的PageController原始碼分析筆記

Flutter—Android混合開發之下載安裝的實現

相關文章