给大家介绍一下如何用Flutter实现类似Scratch那种图形化拖拽的少儿编程效果。计划实现如下几个功能:
-
图形化的编程积木
-
通过拖拽的方式编程
-
支持多个编程指令序列
-
具有磁吸区,编程模块靠近时自动吸附。磁吸区使用浅色标识。
-
指令序列支持拆分,重新排列:用户拖动已经添加的编程模块时,进行拆分,重新排列。
图形化编程结束之后,需要将指令序列通过蓝牙或者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,我们持续更新。下面是我的公众号,欢迎加入,一起学习