玩玩flutter的拖拽——实现一款万能遥控器(代码片段)

唯鹿 唯鹿     2022-12-19     176

关键词:

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

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

AndroidFlutter

因为有之前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。
属性类型说明
childWidget拖动的Widget
feedbackWidget拖动时,在手指指针下显示的Widget
dataT传递的信息
axisAxis可以限制拖动方向,水平或垂直
childWhenDraggingWidget拖动时child的样式
dragAnchorDragAnchor拖动时起始点位置(后面会说到)
affinityAxis手势冲突时,指定以何种拖动方向触发
maxSimultaneousDragsint指定最多可同时拖动的数量
onDragStartedvoid Function()拖动开始
onDraggableCanceledvoid Function(Velocity velocity, Offset offset)拖动取消,指没有被DragTarget控件接受时结束拖动
onDragEndvoid Function(DraggableDetails details)拖动结束
onDragCompletedvoid Function()拖动完成,与取消情况相反
  • DragTarget:用于接收Draggable传递的数据。
属性类型说明
builderWidget Function(BuildContext context, List candidateData, List rejectedData)可通过回调的数据构建Widget
onWillAcceptbool Function(T data)判断是否接受Draggable传递的数据
onAcceptvoid Function(T data)拖动结束,接收数据时调用
onLeavevoid Function(T data)Draggable离开DragTarget区域时调用

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

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

2021-03-10更新:
发现flutter 2.0.0 在Draggable新增onDragUpdate回调、DragTarget新增onMove回调,基本可以满足此项目使用,但无法实现二次拖动。还是需要去修改源码实现。。。

所以这里只能拷出源码修改,自己动手丰衣足食。主要位置是_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、文章点赞)。

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

实现元素简单的拖拽

1.通过元素的offsetLeft,offsettop实现元素的拖拽1<!DOCTYPEhtml>2<html>34<head>5<metacharset="UTF-8">6<title></title>7<styletype="text/css">8.box{9width:100px;10height:100px 查看详情

js实现登陆页面的拖拽功能

<!DOCTYPEhtml><html> <head> <metacharset="UTF-8"> <title>登陆页面的拖拽功能实现</title> </head> <styletype="text/css"> *{ margin:0; padding:0; } a{ text-d 查看详情

通过js实现简单的拖拽功能并且可以在特定元素上禁止拖拽

前言关于讲解JS的拖拽功能的文章数不胜数,我确实没有必要大费周章再写一篇重复的文章来吸引眼球。本文的重点是讲解如何在某些特定的元素上禁止拖拽。这是我在编写插件时遇到的问题,其实很多插件的拖拽功能并没有处... 查看详情

qt中如何实现一个treewidget的拖拽功能

QTreeWidget中有一个分级别的树,只允许同级别之间的拖拽功能,那位会做帮帮小弟吧参考技术AsetDragDropMode(QAbstractItemView::InternalMove) 查看详情

jqgrid最近在用jqgrid,我想要实现列的拖拽功能,请问有人实现过吗

我没说清楚,我要实现jqgrid列的拖拽,不是列宽,是列的顺序拖拽。谢谢参考技术A上一章提到在Jqgrid中如何设置二级表头,这一章节主要探讨Jqgrid表格里面的数据如果实现拖动功能,比如你想把第一行的数据拖到当前页的最后一行... 查看详情

javascript实现网页元素的拖拽效果

以下的页面中放了两个div,能够通过鼠标拖拽这两个元素到任何位置。实现该效果的HTML页面代码例如以下所看到的:<!DOCTYPEhtml><html><headlang="en"><metacharset="UTF-8"><title></title><styletype="text/css">#xixi{wid 查看详情

javascript实现最简单的拖拽效果

...自己做一些操作,所谓自定义。例如:①浏览器标签顺序的拖拽切换现在基本上所有的选项卡式的浏览器都有顺序拖拽切换的功能,如下图:类似的效果我们可以在QQ精要新闻弹出框中看到,见下图:②把内容放在自己喜欢的位... 查看详情

js实现鼠标的拖拽效果

拖拽效果在我们上网的过程中是很常见的,大家都应该在电脑上面登陆过qq吧,当这个qq的登陆框弹出来的时候,我们是可以进行拖动的。这就是一个拖拽效果这是我在慕课网上面看到的,我直接拿过来了,地址http://www.imooc.com/le... 查看详情

cookie结合js实现记住的拖拽

哈喽!!!我胡汉三又回来啦!!!有木有记挂挪啊!咱们今天说一个cookie结合JS的小案例哦!话不多说直接上代码:<!DOCTYPEhtml><html> <head> <metacharset="UTF-8"> <title></title> <style> #drag{ width:200px; 查看详情

jquery实现行列的单向固定和列的拖拽

...t;标签中,而整体的数据放在另一个<table>标签中。列的拖拽:使用onstartdrag、ondragover、drop事件1<!DOCTYPEHTML>2<html>3<head>4<title> 查看详情

jq实现登陆页面的拖拽功能

<!DOCTYPEhtml><html> <head> <metacharset="UTF-8"> <scriptsrc="js/jquery-1.9.1.min.js"type="text/javascript"charset="utf-8"></script> <title></title> 查看详情

jquery实现对div的拖拽功能

<!DOCTYPEhtmlPUBLIC"-//W3C//DTDXHTML1.0Transitional//EN""http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><htmlxmlns="http://www.w3.org/1999/xhtml"><head><metahttp-equiv=" 查看详情

模拟实际项目需求,使用element的日历组件配合h5的拖拽功能实现任务拖拽保存

<!--@Description:简化版的实际项目,模拟在element日历组件上拖拽任务--><!DOCTYPEhtml><htmllang="en"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width,initial-scale=1 查看详情

wpf中如何实现控件的拖拽

想实现这样一个常用功能:在ListBox的一个Item上点住左键,然后拖拽到另外一个控件(如ListView中),松开左键,数据已经拖拽过来。步骤如下:1.设置ListBox的AllowDrop属性为True2.在ListBoxItem的Style中设置EventSetter<Stylex:Key="MyListBoxI... 查看详情

arcgisjs学习笔记2实现仿百度的拖拽画圆

一、前言     吐槽一下,百度在国内除了百度地图是良心产品外,其他的真的不敢恭维。在上一篇笔记里,我已经实现了自定义的地图测量模块。在百度地图里面(其他地图)都有一个周边搜索的功能,拖拽画... 查看详情

wpf这可能是全网最全的拖拽实现方法的总结

原文:【WPF】这可能是全网最全的拖拽实现方法的总结前文本文只对笔者学习掌握的一般的拖动问题的实现方法进行整理和讨论,包括窗口、控件等内容的拖动。希望本文能对一些寻找此问题的解决方法的人和一些刚入门的人一... 查看详情

零代码开发ai语音红外遥控

...们DIY了一款基于涂鸦零代码开发方案的demo——万能红外遥控器,通过手机就可以控制空调,再也不会因为找不到空调遥控器而发愁了。转眼间到了冬天,又是一个开空调的季节,这一次我们做一个升级版的万能红... 查看详情

原生js实现弹出窗口的拖拽(直接copy可用)

  上一篇说了一下弹出窗口功能的实现思路,一般情况下紧接着就会需要做到弹窗的移动,当然现在有很插件、库比如hammer可以使用,效率也非常好。但我觉得还是有必要了解一下原生JS的实现思路及方式,如下:  思路:... 查看详情