玩玩Flutter的拖拽——实现一款万能遥控器

封面

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

先给你们康康最终的实现效果及对比(我的以为还原度很高,甚至Flutter版的更好):git

Android Flutter
Android
Flutter

由于有以前Android版本的实现经验,因此省了很多时间,固然也踩了很多坑,前先后后用了3天时间。下面我来介绍下实现流程。github

UI实现

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

手机

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

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

  • 绘制田字格时外框为实线,里侧为虚线。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实现。大体代码以下:ui

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;
          });
        },
      ),
    );
  }
}

复制代码

拖动实现

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

  • 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 candidateData, List rejectedData) 可经过回调的数据构建Widget
onWillAccept bool Function(T data) 判断是否接受Draggable传递的数据
onAccept void Function(T data) 拖动结束,接收数据时调用
onLeave void Function(T data) Draggable离开DragTarget区域时调用

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

不过这里有个问题,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、文章点赞)。

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

相关文章
相关标签/搜索