
                            
                            因收到Google相关通知，网站将会择期关闭。相关通知内容
                            
                            
                            22 撤销功能与画板优化
                            1. 回退与撤销功能需求分析

如下效果，在头部标题栏的左侧添加两个按钮，分别用于 向前回退 和 撤销回退 ：


向前回退: 移除当前线列表中的最后一条线。
撤销回退: 向当前线列表中添加上次回退的线。





向前回退
撤销回退










由于需要 "后悔"，所以需要引入一个线列表作为 "后悔药"，也就是收集向前回退过程中被抛弃的线。这里在状态类中添加 _historyLines 列表来维护：

List<Line> _historyLines = [];


另外，在界面构建逻辑中，需要注意按钮可操作性的限制，比如当线列表为空时，无法向前回退：



当界面上有内容时，才允许点击左侧按钮回退。撤销按钮同理，只有回退历史中有元素，才可以操作。





2. 回退与撤销界面构建

首先看一下两个按钮的构建逻辑，这里封装一个 BackUpButtons 组件进行维护。其中有两个可空的回调函数，分别用于处理两个按钮的点击事件。当函数为空时，表示当前按钮不可用，呈灰色状态示意，代码中对应的是 backColor 和 revocationColor 颜色的赋值。

IconButton 默认情况下是 48*48 的尺寸，看起来比较大，可以设置 constraints 参数来修改约束，从而控制尺寸。这里用了 Transform 组件，通过 scale 构造让图标按钮沿 Y 轴镜像，就可以得到与右侧对称的效果。

class BackUpButtons extends StatelessWidget {
  final VoidCallback? onBack;
  final VoidCallback? onRevocation;

  const BackUpButtons({
    Key? key,
    required this.onBack,
    required this.onRevocation,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    const BoxConstraints cts = BoxConstraints(minHeight: 32, minWidth: 32);
    Color backColor = onBack == null?Colors.grey:Colors.black;
    Color revocationColor = onRevocation == null?Colors.grey:Colors.black;
    return Center(
      child: Wrap(
        children: [
          Transform.scale(
            scaleX: -1,
            child: IconButton(
              splashRadius: 20,
              constraints: cts,
              onPressed: onBack,
              icon: Icon(Icons.next_plan_outlined,color: backColor),
            ),
          ),
          IconButton(
            splashRadius: 20,
            onPressed: onRevocation,
            constraints: cts,
            icon: Icon(Icons.next_plan_outlined, color: revocationColor),
          )
        ],
      ),
    );
  }
}




然后将 BackUpButtons 放在恰当的位置即可，由于两个按钮属于头部标题，而头部标题栏的构建逻辑封装在 PaperAppBar 中。这里有两种思路：


将 BackUpButtons 封装在 PaperAppBar 内部，将两个按钮的点击事件继续向上传递。
将左侧组件作为插槽位置，通过构造将组件传入到 PaperAppBar 里进行使用。


其实两者本质上没有区别，只是形式上的差异、这里使用前者，这样对于 PaperAppBar 的使用者而言，只需要在意其中的事件，不需要关注标题中构造逻辑。代码如下：



在使用 PaperAppBar 组件时，为 onBack 和 onRevocation 设置回调处理函数。当 _lines 为空，表示不可回退，onBack 设为 null 即可，这样在 BackUpButtons 组件构建时就会将左侧按钮置位灰色，onRevocation 同理。

appBar: PaperAppBar(
  onClear: _showClearDialog,
  onBack: _lines.isEmpty ? null : _back,
  onRevocation: _historyLines.isEmpty ? null : _revocation,
),




3. 状态数据的维护

最后一步就是在 _back 方法中处理回退的逻辑；在 _revocation 中处理撤销的逻辑。在回退方法中移除 _lines 的最后一个元素，然后让 _historyLines 列表添加移除的线，再更新界面即可：

void _back() {
   Line line = _lines.removeLast();
   _historyLines.add(line);
   setState(() {});
}


在撤销回退方法中移除 _historyLines 的最后一个元素，然后让 _lines 列表添加移除的线，再更新界面即可：

void _revocation() {
  Line line = _historyLines.removeLast();
  _lines.add(line);
  setState(() {});
}


到这里， 向前回退和撤销回退的功能就已经实现了，当前代码位置 paper.dart。虽然现在画板操作时看起开没什么问题，但内部是危机四伏的。



4.拖拽更新的频繁触发

如下所示，在拖拽更新的回调 _onPanUpdate 中打印一下日志，会发现它的触发非常频繁。而每触发一次都会像线中添加一个点，就会导致点非常多。



特别是缓慢移动的过程中，会加入很多相近的无用点，不仅占据内存，也会造成绘制的负担。如下所示，右图通过 PointMode.points 模式展示点前的点，可以看出虽然中间点线很短，但非常密集：




PointMode.polygon
PointMode.points










我们可以在收集点时优化一下逻辑，根据与前一点的距离决定加不加入改点，这样可以有效降低点的数量，减缓绘制压力。处理逻辑并不复杂，如下所示，只要校验当前点和线的最后一点的距离，是否超过阈值即可。

void _onPanUpdate(DragUpdateDetails details) {
  Offset point = details.localPosition;
  double distance = (_lines.last.points.last - point).distance;
  if (distance > 5) {
    _lines.last.points.add(details.localPosition);
    setState(() {});
  }
}


阈值越大，忽略的点就越多，线条越不精细，相对来说绘制压力也就越低，需要酌情处理。这里用 5 个逻辑像素，在操作体验上没什么影响，也能达到一定的优化效果。下面是缓慢移动过程中添加点集的情况，可以看出已经避免了点过于密集的问题：




PointMode.polygon
PointMode.points













5. 本章小结

到这里，白板绘制的基础功能就已经完成了，当前代码位置 paper。还有些值得优化和改进的地方，比如：


现在的线是通过点进行连接的折线，可以通过贝塞尔曲线进行拟合，让点之间的连接更加圆滑；
可以提供一些基础图形的绘制操作，让绘制更加丰富。
现在每次添加点都会将所有的内容绘制一边，随着绘制内容的增加，会带来频繁的复杂绘制。
如何存储绘制的信息到本地，这样即使在退出应用后，也可以在下次开启时恢复绘制的内容。


对这些问题的改进，大家可以在今后的路途中通过自己思考和理解，来尝试解决。接下来，我们将对这三个小项目进行整合，放入到一个项目中。

                        
                        
                            