5

封面

前阵子突然想到两年前写过的一篇博客:玩玩Android的拖拽——实现一款万能遥控器,就想着用Flutter来复刻一下。顺便练习一下Flutter里的拖拽Widget。

先给大家康康最终的实现效果及对比(个人觉得还原度很高,甚至Flutter版的更好):

Android Flutter
Android Flutter

因为有之前Android版本的实现经验,所以省了不少时间,当然也踩了不少坑,前前后后用了3天时间。下面我来介绍下实现流程。

UI实现

整个UI分为上下两部分,上半部分为手机(遥控器),下半部分是遥控按钮的选择菜单。

手机

使用CustomPainter来画一个手机外观。这部分都是各种位置计算以及CanvasPaint API的调用。比如画线、圆、矩形、圆角矩形等。

代码就不贴出来了(源码链接在文末),说一下需要注意的一点。

  • 绘制田字格时外框为实线,里侧为虚线。Canvas 貌似没有提供绘制虚线的方法(Android 使用 Paint.setPathEffect来更改样式),所以只能通过循环给Path 添加虚线的路径位置,最终调用CanvasdrawPath方法绘制。 这里我使用了path_drawing库来实现,它封装了这一循环操作,便于使用。
  // 虚线段长4,间隔4
  Path _dashPath = dashPath(_mPath, dashArray: CircularIntervalList<double>(<double>[4, 4]));
  canvas.drawPath(_dashPath, _mPhonePaint);

遥控按钮的选择菜单

这部分很简单,一个PageView,里面用GridView排列好对应的按钮。为了方便实现底部指示器效果,我这里使用了flutter_swiper来替代PageView实现。

按钮

按钮的素材图片本身是没有圆形边框的。其次按钮的按下时会有一个背景色变化。这部分可以通过BoxDecorationGestureDetector实现。大致代码如下:

class _DraggableButtonState extends State<DraggableButton> {
  
  Color _color = Colors.transparent;
  
  @override
  Widget build(BuildContext context) {
    Widget child = Image.asset('assets/image.png', width: 48 / 2, height: 48 / 2,);

    child = Container(
      alignment: Alignment.center,
      height: 48,
      width: 48,
      decoration: BoxDecoration(
        color: _color,
        borderRadius: BorderRadius.circular(48 / 2), // 圆角
        border: Border.all(color: Colours.circleBorder, width: 0.4), // 边框
      ),
      child: child,
    );
    
    return Center(
      child: GestureDetector(
        child: child,
        onTapDown: (_) {
          /// 按下按钮背景变化
          setState(() {
            _color = Colours.pressed;
          });
        },
        onTapUp: (_) {
          setState(() {
            _color = Colors.transparent;
          });
        },
        onTapCancel: () {
          setState(() {
            _color = Colors.transparent;
          });
        },
      ),
    );
  }
}

拖动实现

这里就用到了今天的主角DraggableDragTarget

  • Draggable : 可拖动Widget。
属性 类型 说明
child Widget 拖动的Widget
feedback Widget 拖动时,在手指指针下显示的Widget
data T 传递的信息
axis Axis 可以限制拖动方向,水平或垂直
childWhenDragging Widget 拖动时child的样式
dragAnchor DragAnchor 拖动时起始点位置(后面会说到)
affinity Axis 手势冲突时,指定以何种拖动方向触发
maxSimultaneousDrags int 指定最多可同时拖动的数量
onDragStarted void Function() 拖动开始
onDraggableCanceled void Function(Velocity velocity, Offset offset) 拖动取消,指没有被DragTarget控件接受时结束拖动
onDragEnd void Function(DraggableDetails details) 拖动结束
onDragCompleted void Function() 拖动完成,与取消情况相反
  • DragTarget:用于接收Draggable传递的数据。
属性 类型 说明
builder Widget Function(BuildContext context, List<T> candidateData, List<dynamic> rejectedData) 可通过回调的数据构建Widget
onWillAccept bool Function(T data) 判断是否接受Draggable传递的数据
onAccept void Function(T data) 拖动结束,接收数据时调用
onLeave void Function(T data) Draggable离开DragTarget区域时调用

上面介绍了DraggableDragTarget 的作用及使用属性。那么也就很明显,底部的按钮就是Draggable,上半部的手机屏幕就是DragTarget

不过这里有个问题,Draggable没有提供拖动中的回调(无法获取实时位置),DragTarget也没有提供Draggable在区域中拖动的回调。这导致我们无法实时在手机屏幕上显示“指示投影”。

指示投影

所以这里只能拷出源码修改,自己动手丰衣足食。主要位置是_DragAvatarupdateDrag方法:

void updateDrag(Offset globalPosition) {
  _lastOffset = globalPosition - dragStartPoint;
  ....
  final List<_DragTargetState<T>> targets = _getDragTargets(result.path).toList();

  bool listsMatch = false;
  if (targets.length >= _enteredTargets.length && _enteredTargets.isNotEmpty) {
    listsMatch = true;
    final Iterator<_DragTargetState<T>> iterator = targets.iterator;
    for (int i = 0; i < _enteredTargets.length; i += 1) {
      iterator.moveNext();
      if (iterator.current != _enteredTargets[i]) {
        listsMatch = false;
        break;
      }
      /// TODO 修改处 给DragTargetState添加didDrag方法,回调有Draggable拖动。
      _enteredTargets[i].didDrag(this);
    }
  }
  /// TODO 修改处 给Draggable添加onDrag回调方法,返回拖动中位置
  if (onDrag != null) {
    onDrag(_lastOffset);
  }
  ....
}

详细的改动源码里有注释,这里就不全部贴出了。这下万事俱备,开搞!!

定义拖动传递的数据对象

class DraggableInfo {

  String id;
  String text;
  String img;
  /// 拖动类型
  DraggableType type;
  /// 记录拖动位置
  double dx = 0;
  double dy = 0;

  DraggableInfo(this.id, this.text, this.img, this.type);
  
  setOffset(double dx, double dy) {
    this.dx = dx;
    this.dy = dy;
  }

  @override
  String toString() {
    return '$runtimeType(id: $id, text: $text, img: $img, type: $type, dx: $dx, dy: $dy)';
  }

  @override
  // ignore: hash_and_equals  以id作为唯一标识
  bool operator == (other) => other is DraggableInfo && id == other.id;

}

enum DraggableType {

  /// 1 * 1 文字
  text,
  /// 1 * 1 图片
  imageOneToOne,
  /// 1 * 2 图片
  imageOneToTwo,
  /// 3 * 3 图片
  imageThreeToThree,
}

拖动按钮

因为这里的触发拖动是长按,所以使用LongPressDraggable,用法与Draggable一致。将上面的按钮完善一下:

var child; /// 自定义按钮

LongPressDraggable<DraggableInfo>(
  data: draggableInfo,
  dragAnchor: MyDragAnchor.center,
  /// 最多拖动一个
  maxSimultaneousDrags: 1,
  /// 拖动控件时的样式,这里添加一个透明度
  feedback: Opacity(
    opacity: 0.5,
    child: child,
  ),
  child: child,
  onDragStarted: () {
  /// 开始拖动
  },
  /// 拖动中实时位置回调
  onDrag: (offset) {
    /// 返回点为拖动目标左上角位置(相对于全屏),将位置保存。
    widget.data.setOffset(offset.dx, offset.dy);
  },
),

接收拖动

使用DragTarget来进行拖动数据的更新。

GlobalKey<PanelViewState> _panelGlobalKey = GlobalKey();

DragTarget<DraggableInfo>(
  builder: (context, candidateData, rejectedData) {
    return PanelView( /// 所有的接收数据处理
      key: _panelGlobalKey,
      dropShadowData: candidateData, /// 指示投影数据
    );
  },
  onAccept: (data) {
    /// 目标被区域接收
    _panelGlobalKey.currentState.addData(data);
  },
  onLeave: (data) {
    /// 目标移出区域
    _panelGlobalKey.currentState.removeData(data);
  },
  onDrag: (data) {
    /// 监测到有目标在拖动,绘制指示投影。
    setState(() {

    });
  },
  onWillAccept: (data) {
    /// 判断目标是否可以被接收
    return data != null;
  },
),

数据处理

确定位置与大小

  • 大小主要分为三种:1 1, 1 2, 3 * 3,需要通过传递的DraggableType来确定大小。
  • 拖动返回的位置是相对于全屏的,所以需要globalToLocal转换一下。
Rect computeSize(BuildContext context, DraggableInfo info) {
  /// gridSize为一个田字格大小
  double width = widget.gridSize;
  double height = widget.gridSize;
  if (info.type == DraggableType.imageOneToTwo) {
    width = widget.gridSize;
    height = widget.gridSize * 2;
  } else if (info.type == DraggableType.imageThreeToThree) {
    width = widget.gridSize * 3;
    height = widget.gridSize * 3;
  }

  RenderBox box = context.findRenderObject();
  // 将全局坐标转换为当前Widget的本地坐标。
  Offset center = box.globalToLocal(Offset(info.dx, info.dy));
  return Rect.fromCenter(
    center: center,
    width: width,
    height: height,
  );
}

修正位置

我们拖动中的位置和释放时的位置都不一定准确的放在田字格中,所以我们要修正位置(包括边界超出的处理)。修正位置也可以让“指示投影”给予用户良好的引导。

Rect adjustPosition(DraggableInfo info, Rect mRect) {
  // 最小单元格宽高
  double size = widget.gridSize / 2;

  double left, top, right, bottom;
  // 修正x坐标
  double offsetX = mRect.left % size;
  if (offsetX < size / 2) {
    left = mRect.left - offsetX;
  } else {
    left = mRect.left - offsetX + size;
  }
  // 修正Y坐标
  double offsetY = mRect.top % size;
  if (offsetY < size / 2) {
    top = mRect.top - offsetY;
  } else {
    top = mRect.top - offsetY + size;
  }

  right = left + mRect.width;
  bottom = top + mRect.height;

  //超出边界部分修正
  //因为DragTarget判断长宽大于一半进入就算进入接收区域,也就是面积最小进入四分之一
  if (top < 0) {
    top = 0;
    bottom = top + mRect.height;
  }

  if (left < 0) {
    left = 0;
    right = left + mRect.width;
  }

  if (bottom > widget.gridSize * 7) {
    bottom = widget.gridSize * 7;
    top = bottom - mRect.height;
  }

  if (right > widget.gridSize * 4) {
    right = widget.gridSize * 4;
    left = right - mRect.width;
  }

  return Rect.fromLTRB(left, top, right, bottom);
}

经过这两步,我们的布局边界效果如下:

布局边界效果

避免重叠

避免拖动按钮造成重叠,我们需要逐一对比Rect

/// 判断当前Rect是否有重叠
bool isOverlap(Rect rect, List<Rect> mRectList) {
  for (int i = 0; i < mRectList.length; i++) {
    if (isRectOverlap(mRectList[i], rect)) {
      return true;
    }
  }
  return false;
}

/// 判断两Rect是否重叠(摩根定理)
bool isRectOverlap(Rect oldRect, Rect newRect) {
  return (
    oldRect.right > newRect.left &&
    newRect.right > oldRect.left &&
    oldRect.bottom > newRect.top &&
    newRect.bottom > oldRect.top
  );
}

有重叠的,我们显示一个空Widget。

通过上面的三步处理,我们计算出正确的Rect。最终使用Stack显示出来。

/// 保存放置按钮的Rect
List<Rect> rectList = List();
/// 放置的按钮
List<Widget> children= List.generate(data.length, (index) {
  /// 计算位置及大小
  Rect rect = computeSize(context, data[index]);
  /// 修正
  rect = adjustPosition(data[index], rect);
  rectList.add(rect);
  /// 是否重叠    
  bool overlap = isOverlap(rect, rectList);

  if (overlap) {
    return const SizedBox.shrink();
  }
  /// 涉及widget移动、删除,注意添加key
  var button = DraggableButton(
    key: ObjectKey(data[index]),
    onDragStarted: () {
      /// 开始拖动时,移除面板上的拖动按钮
      removeData(data[index]);
    },
  );

  return Positioned.fromRect(
    rect: rect,
    child: Center(
      child: button,
    ),
  );
});

return Stack(
  children: children,
);

这里需要注意两点:

  • 因为二次拖动时(已放置的按钮,再次长按拖动)涉及Widget删除,为了避免错乱,Draggable 按钮一定要添加key。具体原因及原理见:说说Flutter中最熟悉的陌生人 —— Key
  • 注意避免重复添加同一按钮。因为二次拖动时不一定会触发DragTargetonLeave
addData(DraggableInfo info) {
  /// 避免重复添加同一按钮,这里已重写DraggableInfo的 == 操作符
  if (!data.contains(info)) {
    data.add(info);
  }
}

优化

  • 对于DraggabledragAnchor属性,是为了确定起始点的位置(锚点),有两种模式child与pointer。
  1. DragAnchor.child就是以点击点作为起始点(动态位置)。如果feedbackchild一致,那么feedback它们将重合。
  2. DragAnchor.pointer就是以按钮的左上角(Offset.zero)作为起始点(固定位置)。也就是feedback的左上角将是点击点的位置。

    很遗憾这两种都不是Android原版的效果,原效果以点击点作为feedback的中心点(大家可以仔细观察上面的GIF)。所以我添加了一个锚点类型center,让点击点作为feedback的中心点。也就是x,y各偏移长宽的一半。

  • 在开始拖动时,我们可以添加一个振动反馈。这里可以使用flutter_vibrate库来实现。
LongPressDraggable<DraggableInfo>(
  onDragStarted: () {
    /// 开始拖动
    Vibrate.feedback(FeedbackType.light);
  },
  ....
),
  • 为了避免因拖动按钮时调用setState而造成CustomPainter的不断重绘,这里需要使用RepaintBoundary。具体原因及原理见:说说Flutter中的RepaintBoundary
RepaintBoundary(
  child: CustomPaint(
    /// 绘制手机外形
    painter: PhoneView()
  ),
)

其他

因为DragTargetbuilder 方法返回的candidateData是一个集合,所以可以同时响应多个拖拽信息。数量上限取决于你的手机支持的多点触控数量。这个特点是Android 版本所没有的。(虽然不知道能干什么,牛啤就完事了~~)

多点拖拽

PS:

本篇虽然看似是一个UI效果实现,但其实也是之前的“说说”系列的一个实践总结。上面文章中也有提到过:

没有上面的这三篇作为基础,那么也无法有这样的完成度,推荐大家阅读


到这里我就将整个实现的重点说完了,其他的计算细节这里就不说了,可以去看看源码。奉上Github地址,有兴趣的可以跑起来玩玩。记得不要白嫖,来个素质三连哦(star、fork、文章点赞)。

我在这里提前感谢大家了,你的支持就是我最大的动力!!


唯鹿
142 声望278 粉丝