美文网首页FlutterFlutter UIFlutter知识点
Flutter自定义View——仿高德三级联动Drawer

Flutter自定义View——仿高德三级联动Drawer

作者: 吉哈达 | 来源:发表于2020-07-23 18:06 被阅读0次

    前言

    一直觉得高德地图的首页Drawer滑动起来很漂亮,还有一些科技感,之前用android实现了一遍,趁着最近不忙再用Flutter实现一遍。

    示意图

    为了方便区分布局结构,我使用了不同的颜色

    image

    Drawer高度状态

    可以看到drawer 高度有三种情况:

    最大高度

    距离顶部有一小段空间,这里空间高度定位70,

    drawer的高度为:屏幕高度-70

    image

    中等高度

    这里我们将drawer的显示高度定位300

    image

    最小高度

    这里drawer的显示高度定位150

    image

    Drawer的ui 结构

    image

    可以看到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混合开发之下载安装的实现

    相关文章

      网友评论

        本文标题:Flutter自定义View——仿高德三级联动Drawer

        本文链接:https://www.haomeiwen.com/subject/gpghlktx.html