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

前言

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

示意圖

爲了方便區分佈局結構,我使用了不一樣的顏色git

Drawer高度狀態

能夠看到drawer 高度有三種狀況:github

最大高度

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

drawer的高度爲:屏幕高度-70框架

中等高度

這裏咱們將drawer的顯示高度定位300async

最小高度

這裏drawer的顯示高度定位150ide

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){
            ///在一、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的滑動功能。

多功能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混合開發之下載安裝的實現

相關文章
相關標籤/搜索