Flutter 组件集录 | 后悔药 UndoHistory

在现实世界中,没有后悔药可以吃。但在对于计算机世界来说,撤销、恢复是非常常见的功能。小屁孩在键盘上啪啪一顿输出,把你正在写的重要文档搅得面目全非,Ctrl + Z 轻松救场。编程开发的过程中,我们在不断输入和试错,这颗后悔药是我们敢于前行的底气,大不了重新来过。


1. 简单认识 UndoHistory

UndoHistory 是一个 StatefulWidget 组件,在源码中它主要为输入组件服务,只在 EditeableText 源码中打工。可编辑的文字确实是 Undo 使用的最佳场所。

首先来通过一个小案例体验一下 UndoHistory 的价值。现在有个小需求:

在输入面板上添加两个按钮,分别用于 回退一步撤销回退一步

传统的方法处理这个需求,要自己维护列表,在输入变化时进行收集字符串的工作。另外,并非每个字符变化都需要记录,需要进行节流 throttle 的处理,否则历史列表中将会记录大量字符信息,而绝大多数是没有必要的。这些逻辑交给开发者自己处理,就会比较麻烦。为了简化对输入框回退和撤销的操作,Flutter 通过了 UndoHistory 组件。


2. 案例代码实现

界面布局非常简单,上下结构通过 Column 竖向排列:

  • 上方是两个操作按钮,需要根据是否可回退、可撤销展示是否激活的状态。
  • 下方是普通的 TextFiled 组件,延展高度区域并填充白色。

TextField 组件中有一个 undoController 的参数,可以传入 UndoHistoryController 对象,用于控制 UndoHistory 的内容。它是一个 ValueNotifier 可监听对象,也就是说是否标题栏可以监听它,来感知是否可回退、可撤销的状态数据。

dart 复制代码
final UndoHistoryController _undoController = UndoHistoryController();

@override
void dispose() {
  _undoController.dispose();
  super.dispose();
}
  
Widget _buildInputArea() {
  return TextField(
    undoController: _undoController,
    expands: true,
    maxLines: null,
    minLines: null,
    decoration: InputDecoration(
      filled: true,
      fillColor: Colors.white,
      hoverColor: Colors.transparent,
      border: InputBorder.none,
    ),
  );
}

如下所示,这里封了 _IconAction 组件处理图标按钮的展示效果,包括悬浮时的背景圆角,已经激活状态的 处理。封装完后标题栏的两个按钮就可以轻松复用 _IconAction 实现展示功能。当onTap 事件为null时,表示非激活状态,无法触发交互。

dart 复制代码
class _IconAction extends StatefulWidget {
  final IconData icon;
  final VoidCallback? onTap;

  const _IconAction({super.key, required this.icon, this.onTap});

  @override
  State<_IconAction> createState() => _IconActionState();
}

class _IconActionState extends State<_IconAction> {
  bool _hover = false;

  bool get enable => widget.onTap != null;
  Color? get color => (_hover && enable) ? Colors.grey.withOpacity(0.2) : null;

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      cursor: (_hover && enable) ? SystemMouseCursors.click : SystemMouseCursors.basic,
      onExit: (_) => setState(() => _hover = false),
      onEnter: (_) => setState(() => _hover = true),
      child: GestureDetector(
        onTap: widget.onTap,
        child: Container(
            decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(4)),
            padding: const EdgeInsets.all(4.0),
            child: Icon(
              widget.icon,
              size: 20,
              color: enable ? null : Colors.grey,
            )),
      ),
    );
  }
}

UndoHistoryController 中维护了两个历史记录,一个是输入的历史列表,用于处理回退;另一个是回退的历史列表,用于处理撤销上一次回退,分别对应左右按钮。 UndoHistoryController#undoUndoHistoryController#redo 方法实现回退和撤销回退的功能。

此时,构建顶部栏,可以通过 ValueListenableBuilder 来监听 _undoController 可监听对象。是否可以回退和撤销回退的状态,已经记录在了控制器中。回调构建时取用即可。按钮的事件触发,执行控制器的 undoredo 方法即可。

dart 复制代码
Widget _buildToolBar() {
  return Padding(
    padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4),
    child: ValueListenableBuilder<UndoHistoryValue>(
      valueListenable: _undoController,
      builder: (BuildContext context, UndoHistoryValue value, Widget? child) {
        return Wrap(
          spacing: 4,
          children: <Widget>[
            _IconAction(icon: Icons.undo, onTap: value.canUndo ? _undoController.undo : null),
            _IconAction(icon: Icons.redo, onTap: value.canRedo ? _undoController.redo : null),
          ],
        );
      },
    ),
  );
}

3. UndoHistoryController 承担的角色

仔细思考一下,UndoHistoryController 在功能需求实现过程中。它连接着和 TextFiled 顶部的按钮事件,其既持有状态数据,又具有修改数据的能力,还能触发通知更新。这样的对象在状态管理中,一般称之为视图模型 ViewModel 或业务逻辑层 BLoc


可以从回退按钮的点击事件来体会一下,在交互之后,数据的流向。如下绿色箭头所示:

触发 undo 之后,列表数据变化,触发通知更新。此时顶栏和输入框都监听了 UndoHistoryController ,所以两者的视图都会发生变化。顶栏会根据是否可撤销展示激活与否;输入框中展示的文字会发生变化。

同理,输入框的底层在输入过程中,也一定修改了 UndoHistoryController 的内部数据,并触发通知更新。大家可以自己想想此时的数据流向。


4. UndoHistory 源码简看

下面是 EditableTextState 构建逻辑内 UndoHistory 组件的使用场景,其中我们传入的 undoController 将会为作为构造参数传入。其中 onTriggered 回调时触发 undo 和 redo 的时机,会触发 userUpdateTextEditingValue 方法更新输入的信息:

UndoHistoryState 中维护了一个 _UndoStack 的栈,

这个栈是通过列表 List 实现的,输入框中 UndoHistory 组件使用的泛型是 TextEditingValue。所以本质来看 UndoHistoryState 状态类中,维护了一个 TextEditingValue 列表来容纳输入框的编辑内容。

dart 复制代码
class _UndoStack<T> { 
  _UndoStack(); 
  final List<T> _list = <T>[];

在 initState 中可以看到,UndoHistoryState 会监听输入控制器触发 _push 方法; 监听 UndoHistoryContorller 的变化,触发 onTriggered 来更新输入框内容。


另外,其中定义了节流相关的计时器,时长为 500 ms , 输入变化时的 _push 方法中,会先校验更新的条件。然后将新值放入节流器 _throttledPush 中。

dart 复制代码
late final _Throttled<T> _throttledPush;
Timer? _throttleTimer;
bool _duringTrigger = false;

static const Duration _kThrottleDuration = Duration(milliseconds: 500);

_throttledPush在 initState 中被初始化,触发的函数是为 _stack 添加元素,并更新状态。

_updateState 中会更新 UndoHistoryController 控制器的值,触发通知更新。外界就可以因此感知是否可以回退或取消回退。


到这里,UndoHistory 的基本运转方式就简单了解了一下。虽然 UndoHistory 只在源码中的输入框里发光发热,但是它的价值远不止此。所有需要回退或取消回退的场景,都可以使用它。比如绘制、图片编辑等。后面会结合具体的其他场景,来介绍 UndoHistory 组件自身的使用方式。那本文就到这里,谢谢观看~

相关推荐
AI原吾8 分钟前
探索SVG的奥秘:Python中的svgwrite库
android·开发语言·python·svgwrite
前端小程19 分钟前
使用vant UI实现时间段选择
前端·javascript·vue.js·ui
whyfail41 分钟前
React 事件系统解析
前端·javascript·react.js
小tenten2 小时前
js延迟for内部循环方法
开发语言·前端·javascript
幻影浪子2 小时前
Web网站常用测试工具
前端·测试工具
暮志未晚Webgl2 小时前
94. UE5 GAS RPG 实现攻击击退效果
java·前端·ue5
二川bro2 小时前
Vue2 和 Vue3 区别 — 源码深度解析
前端
软件技术NINI3 小时前
vue组件通信,点击传值,动态传值(父传子,子传父)
前端·javascript·vue.js
暖锋丫3 小时前
echarts实现湖南省地图并且定时轮询
前端·javascript·echarts
余生逆风飞翔3 小时前
前端代码上传文件
开发语言·前端·javascript