Flutter实战手势番外篇之可拖拽悬浮组件

前言

产品需求老是天马行空,一天一个想法一天一个变动。本期需求中遇到一个特殊交互,产品大大但愿在应用中有一个全局浮动按钮入口,但愿用户能够在应用每一个地方都能点击进入到某一个页面,从而增长该功能使用率。其实有点相似于在手机上增长一个快捷入口的悬浮球,将一些层级较深的功能提到一级菜单,能够随时随地都能使用此类功能。html

虽而后面由于UI设计师以为这样的入口体验并不友好砍掉了该需求,但前期我已经实现了Demo功能,因此仍是想记录一下该功能的实现方案。

实现方案

Draggable方式

Flutter提供了Draggable用于进行拖拽使用的组件。主要分为:child(准备拖拽的组件)、childWhenDragging(被拖拽后原点处的组件)、feedback(正在被拖拽的组件)。git

Stack(
    children: <Widget>[
        Positioned(
          left: 100,
          top: 100,
          child: Draggable(
            child: Text("我只是演示使用"),
            childWhenDragging: Text("我被拉出去了😢"),
            feedback: Text("我是拉出去的东西"),
          ),
          onDragEnd: (detail) {
            print(
                "Draggable onDragEnd ${detail.velocity.toString()} ${detail.offset.toString()}");
          },
          onDragCompleted: () {
            print("Draggable onDragCompleted");
          },
          onDragStarted: () {
            print("Draggable onDragStarted");
          },
          onDraggableCanceled: (Velocity velocity, Offset offset) {
            print(
                "Draggable onDraggableCanceled ${velocity.toString()} ${offset.toString()}");
          },          
        ),
    ],
),
复制代码

拖动过程当中分为:onDragStarted(拖动开始)、onDragCompleted(拖动结束时拖拽到DragTarget)、onDraggableCanceled(拖动结束时未拖拽到DragTarget)、onDragEnd(拖动结束),拖动过程方法回调顺序以下:github

上述的拖拽操做结果的不一样须要结合DragTarget能够体现,如拖拽到DragTarget中后抬起时触发onDragCompleted回调,若为拖拽到DragTarget中后抬起时触发onDraggableCanceled回调,经过不一样的回调结果知晓是否拖拽到DragTarget中。对于DragTarget暂时不作过多展开。

了解Draggable使用而后结合Stack和Positioned实现拖拽到全屏任意位置的效果了。api

PS: 须要注意的是onDraggableCanceled的offset是globalPosition,因此须要减去全屏的TopPadding以及若是有ToolBar须要去它的高度。bash

double statusBarHeight = MediaQuery.of(context).padding.top;
double appBarHeight = kToolbarHeight;
Stack(
    children: <Widget>[
        Positioned(
          left: offset.dx,
          top: offset.dy,
          child: Draggable(
            child: Box(),
            childWhenDragging: Container(),
            feedback: Box(),
            onDraggableCanceled: (Velocity velocity, Offset offset) {
              //松手的时候
              //计算偏移量须要注意减去toobar高度和全局topPadding高度
              setState(() {
                this.offset = Offset(
                    offset.dx, offset.dy - appBarHeight - statusBarHeight);
              });
            },
          ),
        ),
        Positioned(
          bottom: 10,
          child: Text("${offset.toString()}"),
        )
    ],
),
复制代码

但在手势操做中会发现正在被拖拽的组件Text非默认样式,目前有两种解决办法:第一种是自定义TextStyle修改样式;第二种是在feedback中嵌套一层Material。app

feedback: Material(
    child: Text("我是拉出去的东西"),
),
复制代码

GestureDetector方式

GestureDetector实现方式自定义程度更高。GestureDetector具体使用已经在Flutter实战之手势基础篇介绍过,有兴趣能够看看。ide

GestureDetector结合Stack和Positioned,经过监听手势操做对Offset偏移量计算实现对组件进行位移和定位。主要使用GestureDetector的onPanUpdate方法,获取到DragUpdateDetails中的delta计算出位移的dx和dy。将原有偏移量加上delta偏移量等于当前位置x,y坐标点,另外结合组件自身大小和屏幕边界值计算出最大和最小偏移量来控制组件最终可移动的最大和最小距离以防止悬浮组件超出屏幕。具体拖拽悬浮窗的详细代码以下:布局

class AppFloatBox extends StatefulWidget {
  @override
  _AppFloatBoxState createState() => _AppFloatBoxState();
}

class _AppFloatBoxState extends State<AppFloatBox> {
  Offset offset = Offset(10, kToolbarHeight + 100);

  Offset _calOffset(Size size, Offset offset, Offset nextOffset) {
    double dx = 0;
    //水平方向偏移量不能小于0不能大于屏幕最大宽度
    if (offset.dx + nextOffset.dx <= 0) { 
      dx = 0;
    } else if (offset.dx + nextOffset.dx >= (size.width - 50)) {
      dx = size.width - 50;
    } else {
      dx = offset.dx + nextOffset.dx;
    }
    double dy = 0;
     //垂直方向偏移量不能小于0不能大于屏幕最大高度
    if (offset.dy + nextOffset.dy >= (size.height - 100)) {
      dy = size.height - 100;
    } else if (offset.dy + nextOffset.dy <= kToolbarHeight) {
      dy = kToolbarHeight;
    } else {
      dy = offset.dy + nextOffset.dy;
    }
    return Offset(
      dx,
      dy,
    );
  }

  @override
  Widget build(BuildContext context) {
    return  Positioned(
        left: offset.dx,
        top: offset.dy,
        child: GestureDetector(
          onPanUpdate: (detail) {
            setState(() {
              offset =
                  _calOffset(MediaQuery.of(context).size, offset, detail.delta);
            });
          },
          onPanEnd: (detail) {},
          child: Box()
          ),
      ),
    );
  }
}

复制代码

将悬浮窗组件AppFloatBox添加到Stack中,另外AppFloatBox必须在最上层不然可能会被其余组件覆盖,总体代码以下:post

Stack(
  fit: StackFit.expand,
  children: <Widget>[
    Container1(),
    Container2(),
    Container3(),
    AppFloatBox(), // 显示在最上方
  ],
)
复制代码

OverlayEntry方式(全局模式)

介绍了以上两种悬浮窗的实现方式但也存在弊端。若是咱们须要在应用全局中实现悬浮窗功能以上两种方式会变得不优雅。由于Positioned依赖于Stack,须要整屏都是在Stack组件包裹下悬浮窗才可以在全屏实现拖拽操做。若应用每一个页面都采用Stack进行布局来管理悬浮窗会变得很是复杂和繁琐,又或者原有项目每一个页面并不都是以Stack做为根组件的(难道还须要对全局布局作一次大改动?)。ui

因此最终采用Overlay是比较优雅和简单的方式。实际上OverlayEntry其实相似与Stack的StatefulWidget,特色是悬浮于全部其余widget之上的组件,能够将想要的视图叠加到全局窗口中,所以只须要在OverlayEntry中加入想要的视图并能一直浮如今全局视图了。

延用上一节的AppFloatBox,经过OverlayEntry建立AppFloatBox而后加入到Overlay中,同时能够经过调用OverlayEntry的remove方法直接从Overlay中移除当前组件。详细代码以下:

static OverlayEntry entry;
Column(
    children: <Widget>[
      RaisedButton(
        child: Text("add"),
        onPressed: () {
          entry?.remove();
          entry = null;
          entry = OverlayEntry(builder: (context) {
            return AppFloatBox();
          });
          Overlay.of(context).insert(entry);
        },
      ),
      RaisedButton(
        child: Text("delete"),
        onPressed: () {
          entry?.remove();
          entry = null;
        },
      ),
    ],
  ),
复制代码

PS:若是非手动添加OverlayEntry可采用 SchedulerBinding.instance.addPostFrameCallback将悬浮窗加入到视图中。

🚀完整代码看这里🚀

参考

相关文章
相关标签/搜索