Flutter拖拽实现简单的图形化编程效果

1,036 阅读5分钟

给大家介绍一下如何用Flutter实现类似Scratch那种图形化拖拽的少儿编程效果。计划实现如下几个功能:

  • 图形化的编程积木

  • 通过拖拽的方式编程

  • 支持多个编程指令序列

  • 具有磁吸区,编程模块靠近时自动吸附。磁吸区使用浅色标识。

  • 指令序列支持拆分,重新排列:用户拖动已经添加的编程模块时,进行拆分,重新排列。

flutter拖拽

图形化编程结束之后,需要将指令序列通过蓝牙或者WiFi发送给机器人,有机器人执行,所以这里支持的编程模块有:开始,前进,后退,左转,结束等。这个功能的核心是下面一行的按钮,每个代表一种编程模块,可以拖拽到上面的编程区域进行图形化编程。

最基础的数据是DraggableInfo,每一个编程模块都是一个DraggableInfo对象。其中的dx和dy是x和y坐标,表示编程模块在屏幕上位置。command是这个模块的功能,是一个enum类型,有Start,Forward,Backward等功能,和机器人支持的指令相关。

class DraggableInfo {  String id;  String text;  CodingCommands command;  double dx = 0;  double dy = 0;}​enum CodingCommands {  Start,  Duration,  Forward,  Backward,  TurnLeft,  TurnRight,  Action1,  Action2,  Action3,  Action4,  Stop}

这个需求最核心的功能是拖拽,Flutter官方提供了相应的Widget进行支持:

  • Draggable:课拖拽的Widget。

  • DragTarget:拖拽的接收目标,用于接收Draggable。

整体功能的实现需要依赖于这两个Widget的几个回调函数

  • Draggable

    • onDragStarted:开始拖动。程序在这里设置各种状态,添加相应的编程模块,进行模块的拆分等

    • onDragUpdate:位置更新。这个回调函数会给出当前的位置,代码需要根据当前位置来更新各个编程模块的dx和dy,从而在UI上进行显示的刷新

  • DragTarget

    • onAccept:拖动结束,接收相应的编程模块。代码在这里将拖动的编程模块插入到相应的位置。

后面会结合UI和Provider对这三个回调函数进行详细的讲解,来梳理如何实现的图形化编程功能。接下来首先介绍一下Provider进行状态管理。

这个设计需要支持的功能比较多,需要使用状态管理的库。Flutter支持Provider,Redux等多种状态管理模式,大家可以根据自己的需求选择。官方推荐的是Provider,下面通过Provider来实现具体的功能。会用到changeNotifier类型的Provider。内部会定义一些状态,当状态改变时,订阅了这个状态的Widget会收到通知,根据新的状态来刷新UI界面,完成状态到界面的展示流程。关于Provider,我后面会有文章专门详细的讲解。

功能实现中的重要状态:

  • _codingMode:当前的模式。这个App支持两种模式,一个是闯关模式(Game),一个是自由编程(FreeStyle)。闯关模式下,每个关卡有不同的任务,小朋友需要按照任务提示进行编程。自由模式下小朋友可以任意发挥。

  • _codeBlockList:界面上需要展示的编程模块。因为需要支持多个编程模块序列,每个序列有多个指令,所以是个二维数组(DraggableInfo)。UI进行展示时,循环遍历即可。

  • _landingBlockIdx:因为需要支持编程模块的插入,所以程序中需要记录下来插入的位置。_landingBlockIdx表明了插入第几个编程模块序列。

  • _landingStepIdx:插入序列中的第几项。

  • _selectedCodeBlock:当前拖动的编程模块。这是一个数组,是因为需要支持拆分。拆分时,手指点击模块及其后面的模块都要被拆分出来,所以是一个数组。

  • _shadowStep:磁吸区。需要记录下来磁吸区的位置,UI界面上需要展示磁吸区,以及根据磁吸区的位置,将受影响的模块进行后移。

  • _opType:当前的操作,有添加操作和拆分操作。

    ​class CodingNotifier extends ChangeNotifier { CodingMode _codingMode = CodingMode.Game; List<List> _codeBlockList = []; int _openedLevelIdx = 0; int _levelIdx = 0; int _taskIdx = 0; int _codeBlockIdx = 0; int _blockStepIdx = 0;​ // 插入编程步骤时的index int _landingBlockIdx = 0; int _landingStepIdx = 0; List _selectedCodeBlock = []; DraggableInfo? _shadowStep; DragOpType _opType = DragOpType.Add; DragStatus _dragStatus = DragStatus.Idle;}​enum CodingMode { Game, FreeStyle }

UI和Provider如何配合实现拖拽

在Draggable当中,注意通过onDragStarted和onDragUpdate回调函数执行相应的操作。

Draggable<DraggableInfo>(    data: widget.data,    /// 最多拖动一个    maxSimultaneousDrags: 1,​    /// 拖动控件时的样式,这里添加一个透明度    feedback: Opacity(      opacity: 0,      child: button,    ),    child: button,    onDragStarted: () async {      onDragStarted?.call();    },​    /// 拖动中位置回调    onDragUpdate: (DragUpdateDetails offset) {      codingNotifier.updatePosition(offset.globalPosition);    },  )    

onDragStarted要去分两种情况

  • 增加新的编程模块

  • 拆分现有的编程模块

用户拖动下方的编程模块按钮是增加新的编程模块,这是一个需要将拖动的编程模块加入到_selectedCodeBlock状态中,同时设置当前操作为:Add。

  DraggableButton(    data: item,    onDragStarted: () {      _logger.info("Add: item ${item.toString()} start to drag");      codingNotifier.setDragStatus(DragStatus.Working);      codingNotifier          .onSelected4Add(item.copyWith(id: Uuid().toString()));    })

  onSelected4Add(DraggableInfo item) {    _logger.info(        "Add: _codeBlockIdx= $_codeBlockIdx, _blockStepIdx= $_blockStepIdx, _codeBlockList len = ${_codeBlockList.length}");​    setOpType(DragOpType.Add);    setSelectedCodeBlock([item]);    _logger.info(_selectedCodeBlock.toString());  }

用户拖动已经添加到屏幕上的编程模块时,时进行拆分。此时需要将需要拆分的编程指令序列从_codeBlockList中删除,并且添加到_selectedCodeBlock中。

DraggableButton(    data: blockDetail[idx],    onDragStarted: () {      _logger.info("Move: item $stepIdx start to drag");      codingNotifier.setDragStatus(DragStatus.Working);      codingNotifier.onSelected4Move(blockIdx: i, stepIdx: stepIdx);    },  )

  onSelected4Move({required int blockIdx, required int stepIdx}) {    setOpType(DragOpType.Move);    setCodeBlockIdx(blockIdx);    setBlockStepIdx(stepIdx);​    /// 开始拖动时,移除面板上的拖动按钮    /// 拖动第一个时,全部跟着移动    if (stepIdx == 0) {      // 先赋值再删除      setSelectedCodeBlock(_codeBlockList[blockIdx]);      removeBlockByIdx(index: blockIdx);    } else {      final items = _codeBlockList[_codeBlockIdx];      final start = stepIdx;      List<DraggableInfo> arr = items.sublist(start, items.length);      setSelectedCodeBlock(arr);​      removeStepByIdx(index: stepIdx);    }​    _logger.info("Move: _codeBlockList= ${_codeBlockList.toString()}");    _logger.info("Move:  _selectedCodeBlock= ${_selectedCodeBlock.toString()}");  }

在回调函数onDragUpdate中,我们能拿到拖动Widget的位置。代码需要根据新的坐标进行位置更新

  • 更新拖动序列的位置

  • 更新磁吸区的位置

    updatePosition(Offset offset) { if (_dragStatus != DragStatus.Idle) { // 更新拖动序列的位置 updateSelectedBlockPosition(offset);​ // 在这里遍历更新磁吸区 updateShadow(offset);​ notifyListeners(); } }

当用户进行的是拆分操作时,拖动的是一个编程模块序列,是一个数组,我们拿到的是数组第一项的新坐标,需要根据这个坐标以及每一个编程模块的宽度和高度进行位置更新

  updateSelectedBlockPosition(Offset offset) {    // update _selectedCodeBlock position    // the selected block is a list,    // your finger position is the center of the first item    // every element afterward need to accumulate the width of previous item    double innerOffset = 0;    double previousWidth = 0;    for (int i = 0; i < _selectedCodeBlock.length; i++) {      final DraggableInfo codeStep = _selectedCodeBlock[i];      final curWidth = getCodeStepWidth(codeStep.command);      final curHeight = getCodeStepHeight(codeStep.command);​      innerOffset += curWidth / 2 + previousWidth / 2;      // curHeight / 4 是因为右侧有一个突出,突出的正方形变长是编程方块的高度一半      final newDx = offset.dx + curWidth / 2 + curHeight / 4 + innerOffset;      final newDy = offset.dy;​      _logger.config("dx: $newDx, dy: $newDy ");​      codeStep.setOffset(newDx, newDy);​      previousWidth = curWidth;    }  }

屏幕上有可能有多个编程序列,用户可以将新的编程模块插入任意一个编程序列的任意位置,所以更新磁吸区时,我们需要首先遍历整个_codeBlockList,找到距离拖动Widget距离最近的一个模块,然后和我们设定的最大距离进行对比。小于最大距离的就认为符合磁吸条件,增加磁吸区,当用户放手时有自动吸附效果。

  updateShadow(Offset offset) {    // update _shadowStep position    DraggableInfo? minDistanceCodeStep;    num minDistance = 99999999999.99;    for (int i = 0; i < _codeBlockList.length; i++) {      final List<DraggableInfo> curCodeBlock = _codeBlockList[i];​      for (int j = 0; j < curCodeBlock.length; j++) {        final DraggableInfo curCodeStep = curCodeBlock[j];​        if (curCodeStep.dx > offset.dx) {          // shadow can only be placed on the right side          continue;        }​        final curDistance = pow((curCodeStep.dx - offset.dx), 2) +            pow((curCodeStep.dy - offset.dy), 2);​        if (curDistance < minDistance) {          _landingBlockIdx = i;          _landingStepIdx = j;          minDistance = curDistance;          minDistanceCodeStep = curCodeStep;        }      }    }​    final candidateShadowWidth =        getCodeStepWidth(minDistanceCodeStep?.command ?? CodingCommands.Stop);    final candidateShadowHeight =        getCodeStepHeight(minDistanceCodeStep?.command ?? CodingCommands.Stop);    final selectedHeadStepWidth =        getCodeStepWidth(_selectedCodeBlock[0].command);    final selectedHeadStepHeight =        getCodeStepHeight(_selectedCodeBlock[0].command);​    final maxAllowedDistance =        pow((selectedHeadStepWidth * 2 + candidateShadowWidth) / 2, 2) +            pow((selectedHeadStepHeight + candidateShadowHeight) / 2, 2);​    if (minDistance < maxAllowedDistance) {      final DraggableInfo step =          _selectedCodeBlock[0].copyWith(id: Uuid().toString());      // adjust position to the right      step.setOffset((minDistanceCodeStep?.dx ?? 0) + selectedHeadStepWidth,          minDistanceCodeStep?.dy ?? 0);​      setShadowStep(step);​      _logger.config("Need shadow step: ${step.toString()}");    } else {      setShadowStep(null);    }  }

UI如何根据状态进行展示

在展示的Widget中,在build函数中通过Provider拿到blockList,shadowStep等各种状态。Provider中的状态发生改变时,这里会收到通知,当前Widget会根据新的状态进行重绘。

    final CodingNotifier codingNotifier = Provider.of<CodingNotifier>(context);    final DraggableInfo? shadowStep = codingNotifier.getShadowStep();    final List<DraggableInfo> selectedCodeBlock =        codingNotifier.getSelectedCodeBlock();    final blockList = codingNotifier.getCodingBlockList();    final landingBlockIdx = codingNotifier.getLandingBlockIdx();    final landingStepIdx = codingNotifier.getLandingStepIdx();    final bool isShowCmd = codingNotifier.getIsShowTapActionCmd();    final int codeBlockIdx = codingNotifier.getCodeBlockIdx();    final int blockStepIdx = codingNotifier.getBlockStepIdx();

界面的展示包含三个部分:

  • 已经添加的编程模块:codingBlockList

  • 拖动中的编程模块:selectedCodeBlock

  • 磁吸区:shadowStep

所以代码中Stack Widget,将这三部分都添加到Stack 的Children中进行展示。需要注意的是,我们在展示已经添加的编程模块时,需要根据磁吸区的位置进行调整。将磁吸区影响到的模块全部向后后移,给磁吸区腾出空间。

if (shadowStep != null &&    i == landingBlockIdx &&    idx > landingStepIdx) {  stepIdx = idx + selectedBlockLength;  rect =      adjustWidgetPosition(rect, getCodeStepWidth(shadowStep.command));}

Rect adjustWidgetPosition(Rect mRect, double offsetRight) {  double left, right;​  left = mRect.left + offsetRight;​  right = mRect.right + offsetRight;​  return Rect.fromLTRB(left, mRect.top, right, mRect.bottom);}

以上就是使用Flutter实现图形化少儿编程时需要注意的细节和代码,关于Flutter,我们持续更新。下面是我的公众号,欢迎加入,一起学习