客户端开发

在三维场景里接入中文输入法:老式打字机的 RenderEditable 对齐与非阻塞动画

记录三维打字机放弃自建字符网格后,以 TextEditingController 为单一状态源,读取真实 RenderEditable 光标几何驱动字车与露纸滚动,并解耦中文输入法组合态与机械击键动画的过程。

约 12 分钟读完
目录
  1. 1. 外部独立测量与真实 RenderEditable 的换行偏差
  2. 2. 直接读取 RenderEditable 光标几何驱动字车与卷纸
  3. 3. 中文输入法组合态(composing)与按键交互边界
  4. 4. 文本提交与机械击键动画的非阻塞解耦

书房桌面上有一台可交互的老式机械打字机(54 枚键帽、44 根扇形字杆、横向步进字车与卷筒纸)。如果只考虑纯英文输入,最直接的做法是拦截键盘按键事件,自己维护一个二维字符数组,每按一个字母就把字车往右移一格。

但一旦希望访客能在这台打字机上正常使用中文拼音输入法留言、支持粘贴长段文字、移动光标回改历史行以及按回车卷纸,自建字符网格的做法就不再适用了:拼音输入过程有未上屏的组合串(composing)、中英文混排宽度不一、软换行位置由排版引擎决定,手写状态机很容易与系统输入法状态不一致。

因此从一开始就定了一条原则:文本、选区与输入法状态统一交由标准的 TextEditingController 和 EditableText 维护,三维字车、滚筒、字锤与音效只根据编辑结果做被动视觉反馈。但在把真实编辑器接到三维打字机纸面上的过程中,还是遇到了软换行行数漂移、露纸高度跳动与组合态误触发几处具体问题。

1. 外部独立测量与真实 RenderEditable 的换行偏差

初版实现里,纸面文字虽然由 TextField(底层 EditableText)负责渲染,但为了算出当前光标在第几行、第几列以驱动三维字车横向移动和卷纸高度,外围另外建了一个相同容器宽度(372 px)的 TextPainter 去推算行列。

在短句和手动回车测试里这套双轨计算看起来没问题,但输入到 20 行以上的连续中英混排长段时,问题就暴露出来了:纸面上的真实插入点已经换到了下一行,三维字车还停在上一行末尾,甚至出现新旧行裁切半截、页眉与正文重叠的现象。用一组 560 字符的固定测试文本复现时,外围 TextPainter 算出来是 25 行,而实际的 RenderEditable 排出了 26 行。

逐项隔离变量后,发现两边的排版条件存在两处容易漏掉的差异:

  1. 主题继承样式差异:TextField 实际解析的文本样式合并了外层 Material 主题里的默认 letterSpacing: 0.5,而外围独立创建的 TextPainter 未指定该字段(关于组件树默认样式继承对测量的影响,此前在 TextPainter 测量够宽,Text 为什么仍然被裁切? 中单独记录过)。
  2. RenderEditable 的光标预留宽度:即便把字体、字号(16 px)、StrutStyle 与 letterSpacing: 0.5 全部对齐,Flutter 3.47.9 的 RenderEditable 在多行排版时还会从容器最大宽度中减去光标本身占用的预留空间(默认光标宽 2 px 加 1 px 间隙,使 372 px 容器的实际折行可用宽度变为 369 px)。例如输入一个汉字 "我" 后紧跟 39 个小写字母 "a",独立 TextPainter 在 maxWidth: 372 下排为 2 行,在 369 下排为 3 行,而宽度同为 372 的实际 TextField 光标正好落在第 3 行。

这说明只要还在编辑器外部单独重算一套排版,假设后续主题字距、光标宽度或引擎内部折行细节稍有变化,两边还是会出现错位。

2. 直接读取 RenderEditable 光标几何驱动字车与卷纸

既然界面上已经挂载了真实的 EditableText,最稳妥的做法就是停止在实时输入阶段用字符数或外部 TextPainter 估算行列,改为在每帧布局完成后,直接向 EditableTextState.renderEditable 查询当前选区端点的局部光标矩形(getLocalRectForCaret):

int _rowAt(RenderEditable editable, TextPosition position) {
  final caret = editable.getLocalRectForCaret(position);
  final contentY = caret.top + _scroll.offset;
  return (contentY / editable.preferredLineHeight).round();
}

void _scheduleMetrics() {
  if (_scheduled) return;
  _scheduled = true;
  WidgetsBinding.instance.addPostFrameCallback((_) {
    _scheduled = false;
    if (!mounted) return;
    final value = widget.document.editor.value;
    final editable = _editableState?.renderEditable;
    if (editable == null || !_scroll.hasClients) return;

    final position = value.selection.isValid
        ? value.selection.extent
        : TextPosition(offset: value.text.length);
    final caret = editable.getLocalRectForCaret(position);
    final row = _rowAt(editable, position).toDouble();
    final column = (caret.left / TypewriterPaperLayout.textWidth * 60)
        .clamp(0, 60)
        .toDouble();

    final editorTop = math.max(
      TypewriterPaperLayout.headerHeight,
      TypewriterPaperLayout.strikeLineOffset -
          row * TypewriterPaperLayout.lineHeight,
    );
    // 更新字车列号、行号与纸张露出高度……
  });
}

这里有三个需要配合处理的细节:

  • 固定行高并用四舍五入求行号:通过 StrutStyle(forceStrutHeight: true) 锁定每行高度一致;计算视觉行号时对 (caret.top + scrollOffset) / preferredLineHeight 做 .round() 而不是直接 floor(),避免行边界附近的浮点微差把第 N 行误判成第 N - 1 行。
  • 固定纸面画布总尺寸以防缩放抖动:纸面刚塞入滚筒时只露出一小截,随着换行逐渐向上升起展开。如果直接改变外层组件高度再套 FittedBox,每敲一行字整张纸的字号都会被重新拉伸缩放。改为保持纸面逻辑画布尺寸恒定,前 10 行通过上移内部视口模拟纸张从滚筒吐出,超过一页高度后再切换为内部滚动偏移对齐击打线(strikeLineOffset)。
  • 坐标空间对齐:计算字车横向列位置时,用的是编辑器内容区局部的 caret.left 除以实际正文区宽度 textWidth,不能把两侧纸边距混进列宽比例里。

3. 中文输入法组合态(composing)与按键交互边界

接好标准编辑器之后,英文敲击、复制粘贴和方向键回改都可以正常工作,但中文拼音输入法还有两处体验细节需要单独处理:

第一处是拼音组合期与空格上屏的机械反馈区分。敲拼音字母选词时,TextEditingValue.composing 处于非空有效区间,此时候选词还没真正落纸。如果每按一下拼音键都触发字锤击打滚筒并播放金属音效,体验会很不自然;更常见的问题是按空格键确认首选词上屏时,物理键盘事件先收到一个 Space,紧接着控制器收到整串汉字提交——如果不做区分,空格键会误触发第 32 号空白字杆空敲一次,或者漏掉上屏那一刻的击纸反馈。

现在的拆分方式是:物理按键按下且处于 composing 组合期时,只让对应键帽做轻微下压而不触发字锤击纸;等到 TextEditingController 检测到非组合区的已提交正文真正增长时,再触发字锤起落、色带微抬与击纸音效(如果是组合期刚结束的同帧提交,自动复用刚才那枚物理键帽动画而不重复计次)。

第二处是 Escape 键的优先级冲突。在三维书房聚焦打字机时,按 Escape 原本用于退出打字机特写镜头回到全景;但如果用户正在用中文输入法打半截拼音,按 Escape 的直觉预期是取消当前拼音候选串,而不是直接把镜头退回全景。因此在键盘拦截层加了一条前置判断:只要当前 composing 区间仍处于激活态,优先把按键交给系统输入法处理,等组合态清空后再次按 Escape 才退出聚焦视角。

4. 文本提交与机械击键动画的非阻塞解耦

真实机械打字机每敲一锤再回弹大约需要 140 ms(回车拨杆推回字车则需要 380 ms 左右)。假设把文本输入和动画做成同步串行——等上一根字杆落回原位才允许下一个字符写入,稍微打快一点或者粘贴一句话,不是丢字就是动画队列越积越长,松手几秒后打字机仍在继续播放滞后的敲击动画。

所以在 TypewriterMotion 里严格切断了反向依赖:

void feedback({
  String? key,
  bool strike = false,
  bool carriageReturn = false,
}) {
  // 动画只作视觉反馈:新击键到来时直接接管未走完的旧动画,输入永不等待。
  final augmentsPhysicalKey =
      key == null &&
      (strike || carriageReturn) &&
      _stroke != null &&
      !_stroke!.strike &&
      !_stroke!.carriageReturn;
  if (augmentsPhysicalKey) {
    key = _stroke!.key;
  }
  if (!augmentsPhysicalKey && _stroke != null && _elapsed < _duration) {
    _skippedStrokeCount++;
  }
  _stroke = (key: key, strike: strike, carriageReturn: carriageReturn);
  _elapsed = 0;
  _duration = carriageReturn ? .38 : .14;
}

无论手速多快或是直接粘贴一整段文字,TextEditingController 都在当帧立即完整接收全部文本,不会因为动画未播完而阻塞或漏字;视觉层只保留最新一次击键脉冲并平滑插值字车目标列号(同时累计 skippedStrokeCount 供调试核对合并次数),Web Audio 音效侧则配上 8 ms 的多复音去抖窗口,避免粘贴瞬间同帧叠加几十个音频节点导致爆音。

这套方案目前也还有两处明确的边界:一是通过遍历 TextField 子树查找 EditableTextState 对框架内部结构有一定依赖,每次升级 Flutter SDK 时需要跑一遍包含 26 行软换行、显式回车、光标回退与中文混排的专项 Widget 回归测试;二是编辑器局部光标对齐只解决了二维纸面与三维字车步进的一致性,最终落到三维透视相机镜头里是否和滚筒击打点视觉重合,仍然需要在实机特写视角下配合模型比例做最终核验。