【Flutter】番外篇之手势可拖拽悬浮组件

7,938 阅读4分钟

前言

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

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

实现方案

Draggable方式

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

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(拖动结束),拖动过程方法回调顺序如下:

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

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

PS: 需要注意的是onDraggableCanceled的offset是globalPosition,所以需要减去全屏的TopPadding以及如果有ToolBar需要去它的高度。

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。

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

GestureDetector方式

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

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必须在最上层否则可能会被其他组件覆盖,整体代码如下:

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

OverlayEntry方式(全局模式)

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

所以最终采用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将悬浮窗加入到视图中。

🚀完整代码看这里🚀

参考