大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~

前言

支付金额、转账金额、充值金额这类输入,看起来只是一个 TextInput,真正落到交互上却比普通文本框多不少约束:用户主要输入数字,需要小数点、删除、清空和完成;编辑已有金额时还要处理光标和选区;键盘弹出后又不能把输入框或页面关键操作区域挡住。

这种场景不一定要完全交给系统键盘。

HarmonyOS 官方已经提供自定义键盘能力。当前官方指南明确说明,TextArea、TextInput、RichEditor、Search 等输入控件可以通过 customKeyboard 绑定自定义键盘;绑定后,输入控件获得焦点时加载指定的自定义键盘,而不是系统键盘。

这次就围绕一个足够具体的场景展开:给金额 TextInput 做一套只包含数字、小数点、删除、清空和完成操作的自定义键盘,并处理光标与页面避让。

一、金额输入为什么适合专用键盘

普通文本输入强调的是通用性,金额输入强调的则是业务约束。

例如,一个简单的金额输入框可能只允许:

  • 数字 0~9;
  • 最多一个小数点;
  • 小数点后最多两位;
  • 在光标当前位置插入或删除;
  • 一键清空;
  • 点击“完成”结束编辑。

如果继续使用通用系统键盘,业务层仍然要处理金额格式。而使用 customKeyboard 后,键盘本身就可以只暴露业务真正需要的按键,输入路径更直接。

不过这里有一个容易混淆的地方:自定义键盘负责的是输入交互,金额是否合法仍然应该由业务逻辑判断。 比如“两位小数”“整数最多八位”并不是 customKeyboard 自动提供的规则,需要应用自己实现。

二、先把版本和官方能力边界确认清楚

本文以 HarmonyOS 7 / API 26.0.0 为当前开发基线。华为官方升级适配文档明确说明,HarmonyOS 7.0 对应 API 26.0.0,并建议升级开发套件并完成对应版本适配。

本文涉及的主体是 ArkUI 文本输入能力,不需要引入三方库,也没有为了实现 customKeyboard 而增加额外的运行时权限。

几个核心点可以先整理出来:

能力本文用途官方依据
TextInput金额输入框ArkUI 单行文本输入组件
customKeyboard绑定自定义数字键盘官方自定义键盘指南
@Builder描述键盘 UI@Builder 自 API version 7 起支持
onTextSelectionChange获取光标/选区官方自定义键盘及光标 FAQ
TextInputController.caretPosition()修改文本后恢复光标官方自定义键盘指南
TextInputController.stopEditing()点击“完成”收起键盘官方指南和 FAQ
supportAvoidance开启系统自定义键盘避让官方自定义键盘指南

还有一个版本问题需要特别说明:当前 HarmonyOS 7 文档已经采用新的 API 版本体系,本文代码以 API 26.0.0 文档为核验基线,不根据旧资料反推 ArkTS customKeyboard 的最早首发版本。官方 Native ArkUI C API 中的 NODE_TEXT_INPUT_CUSTOM_KEYBOARD 标注起始版本为 API 12,但这是 Native ArkUI 属性接口,不能直接拿来当作 ArkTS TextInput.customKeyboard 的首发版本。

如果项目还需要兼容较旧的 HarmonyOS API,应在 DevEco Studio 对应 SDK/API Reference 下重新检查目标版本,而不是把 Native 和 ArkTS 的版本号混在一起。

三、搭一个最小金额输入场景

这个示例只做一件事:

一个 TextInput 负责展示金额;输入框获得焦点后弹出自定义键盘;键盘包含 0~9、.、删除、清空、完成。

金额规则设为:

  • 整数部分最多 8 位;
  • 小数部分最多 2 位;
  • 只允许一个小数点;
  • 空内容时点击小数点,自动形成 0.。

这几条属于示例业务规则,并不是 HarmonyOS API 的限制。

自定义键盘本身使用 @Builder 描述。官方文档说明,customKeyboard 的第一个参数是用于描述自定义 UI 的 CustomBuilder;官方 FAQ 也特别提醒,自定义键盘构建函数应正确使用 @Builder。

四、核心代码:TextInput + customKeyboard

下面代码按照官方 TextInput、customKeyboard、onTextSelectionChange、caretPosition() 和 stopEditing() 的使用方式组织成一个最小金额输入示例。

@Entry
@Component
struct AmountInputPage {
  @State amount: string = '';

  private controller: TextInputController = new TextInputController();
  private selectionStart: number = 0;
  private selectionEnd: number = 0;
  private targetCaret: number = 0;

  @Builder
  keyboardKey(label: string) {
    Button(label, { type: ButtonType.Normal })
      .layoutWeight(1)
      .height(52)
      .fontSize(20)
      .onClick(() => {
        this.handleKey(label);
      })
  }

  @Builder
  amountKeyboard() {
    Column({ space: 8 }) {
      Row({ space: 8 }) {
        this.keyboardKey('1')
        this.keyboardKey('2')
        this.keyboardKey('3')
      }

      Row({ space: 8 }) {
        this.keyboardKey('4')
        this.keyboardKey('5')
        this.keyboardKey('6')
      }

      Row({ space: 8 }) {
        this.keyboardKey('7')
        this.keyboardKey('8')
        this.keyboardKey('9')
      }

      Row({ space: 8 }) {
        this.keyboardKey('.')
        this.keyboardKey('0')
        this.keyboardKey('删除')
      }

      Row({ space: 8 }) {
        this.keyboardKey('清空')
        this.keyboardKey('完成')
      }
    }
    .padding(12)
    .height(300)
    .backgroundColor('#F1F3F5')
  }

  private handleKey(key: string): void {
    if (key === '删除') {
      this.deleteBackward();
      return;
    }

    if (key === '清空') {
      this.amount = '';
      this.targetCaret = 0;
      this.selectionStart = 0;
      this.selectionEnd = 0;
      return;
    }

    if (key === '完成') {
      this.controller.stopEditing();
      return;
    }

    this.insertValue(key);
  }

  private insertValue(value: string): void {
    const start = Math.min(this.selectionStart, this.selectionEnd);
    const end = Math.max(this.selectionStart, this.selectionEnd);

    let insertText = value;
    if (value === '.' && this.amount.length === 0) {
      insertText = '0.';
    }

    const candidate =
      this.amount.slice(0, start) +
      insertText +
      this.amount.slice(end);

    if (!this.isValidAmount(candidate)) {
      return;
    }

    this.amount = candidate;
    this.targetCaret = start + insertText.length;
    this.selectionStart = this.targetCaret;
    this.selectionEnd = this.targetCaret;
  }

  private deleteBackward(): void {
    const start = Math.min(this.selectionStart, this.selectionEnd);
    const end = Math.max(this.selectionStart, this.selectionEnd);

    if (start !== end) {
      this.amount =
        this.amount.slice(0, start) +
        this.amount.slice(end);

      this.targetCaret = start;
    } else if (start > 0) {
      this.amount =
        this.amount.slice(0, start - 1) +
        this.amount.slice(start);

      this.targetCaret = start - 1;
    } else {
      return;
    }

    this.selectionStart = this.targetCaret;
    this.selectionEnd = this.targetCaret;
  }

  private isValidAmount(value: string): boolean {
    if (value.length === 0) {
      return true;
    }

    let dotCount = 0;

    for (let i = 0; i < value.length; i++) {
      const ch = value[i];

      if (ch === '.') {
        dotCount++;
        if (dotCount > 1) {
          return false;
        }
      } else if (ch < '0' || ch > '9') {
        return false;
      }
    }

    const parts = value.split('.');

    if (parts[0].length > 8) {
      return false;
    }

    if (parts.length === 2 && parts[1].length > 2) {
      return false;
    }

    return true;
  }

  build() {
    Column({ space: 24 }) {
      Text('付款金额')
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .alignSelf(ItemAlign.Start)

      TextInput({
        placeholder: '0.00',
        text: $$this.amount,
        controller: this.controller
      })
        .height(56)
        .fontSize(24)
        .onTextSelectionChange((start: number, end: number) => {
          this.selectionStart = start;
          this.selectionEnd = end;
        })
        .onChange(() => {
          this.controller.caretPosition(this.targetCaret);
        })
        .customKeyboard(
          this.amountKeyboard(),
          { supportAvoidance: true }
        )

      Text(`当前金额:${this.amount}`)
        .fontSize(16)
        .alignSelf(ItemAlign.Start)
    }
    .width('100%')
    .padding(24)
  }
}

这段代码没有声称已经在具体设备上编译或真机运行,实际项目仍应使用目标 HarmonyOS 7 设备和项目自身的 SDK 配置进一步验证。

五、真正需要关注的是光标,而不只是数字按钮

如果金额输入永远只能从末尾追加,逻辑其实很简单:

12.3 → 点击 4 → 12.34

问题出现在用户点击输入框中间以后。

例如当前内容为:

1234.56
  ^

用户把光标移动到 2 后面,再点击数字键,这个数字应该插入光标位置,而不是直接追加到字符串末尾。

官方自定义键盘指南给出的思路就是:通过 onTextSelectionChange 获取当前选择位置,在内容修改后通过 TextInputController.caretPosition() 设置新的光标位置。

华为官方 FAQ 也专门解释了“输入字符后光标跳到最后”的问题:当文本重新赋值后,如果没有重新定位光标,就容易出现光标回到末尾的现象;官方示例同样记录选择位置,并在内容变化后调用 caretPosition()。

所以这里记录的不是一个 caret,而是:

private selectionStart: number = 0;
private selectionEnd: number = 0;

这是因为 TextInput 不只有“光标”,还可能存在一段选区。

如果用户选中了:

12[34].56

再点击 8,更自然的编辑结果是:

128.56

因此插入和删除逻辑都应该先判断 start 和 end 是否相同。

六、删除和清空最好分开处理

金额键盘里的“删除”和“清空”不是一回事。

删除键应该遵循文本编辑语义:

有选区时删除整个选区;没有选区时删除光标前一个字符;光标已经位于开头时不处理。

而清空属于业务快捷操作,直接把金额设为空即可。

这种处理还有一个好处:键盘组件只负责产生“删除”“清空”这样的动作,金额字符串如何变化仍由页面状态统一管理,不需要让每个 Button 自己操作字符串。

七、收起自定义键盘不要再绕回输入法服务

自定义键盘已经绑定到 TextInputController 后,点击“完成”可以直接:

this.controller.stopEditing();

HarmonyOS 官方自定义键盘指南明确给出了通过 TextInputController.stopEditing() 关闭自定义键盘的方式。

这比为了一个 TextInput 再额外引入输入法控制逻辑更直接。

官方关于 TextInput 收起软键盘的 FAQ 同样给出 controller.stopEditing() 方案;当页面存在多个输入框、需要统一管理输入会话时,才可能考虑输入法框架提供的其他控制方式。

八、自定义键盘弹出来以后,页面怎么避让

这是 customKeyboard 很容易被忽略的一部分。

官方提供了:

.customKeyboard(
  this.amountKeyboard(),
  { supportAvoidance: true }
)

supportAvoidance: true 用来开启系统提供的自定义键盘避让能力。

但这里不要理解成“整个页面都会自动完美避让”。

官方文档明确说明:系统提供的自定义键盘避让主要保证输入框本身不被键盘遮挡,输入框下面的其他组件仍然可能被挡住。对于需要整体上移、底部操作区也必须可见的页面,官方给出的进一步方案是监听自定义键盘根节点的 onAreaChange,获得键盘高度,再根据业务布局调整页面避让距离。

所以实际项目可以分成两层处理:

简单金额页先开启 supportAvoidance;如果页面底部还有“确认付款”“下一步”等关键按钮,再根据键盘实际高度调整页面 padding 或其他布局空间。

九、自定义键盘为什么有时第一次弹出的还是系统键盘

如果后续项目需要在“系统键盘”和“自定义键盘”之间动态切换,这里还有一个官方已经记录过的问题。

华为 FAQ 指出,customKeyboard 的自定义 UI 描述需要正确使用 @Builder;同时,如果先修改“是否绑定自定义键盘”的状态,然后在同一帧立即通过当前帧生效的焦点接口请求焦点,可能出现自定义键盘尚未完成更新、第一次仍拉起系统键盘的情况。官方给出的处理方式之一,是让组件在后续帧获取焦点,例如使用相应的 focusControl.requestFocus() 方案。

本文示例始终绑定自定义金额键盘,因此不需要做动态切换。但如果项目后面加入“数字键盘 / 系统键盘”切换按钮,这个时序问题就应该一起检查。

十、敏感输入场景不能只停留在“自定义键盘”

金额本身未必属于密码类输入,但支付页往往还会出现银行卡、验证码、账户信息等敏感数据。

这里需要把两个概念分清楚:

自定义键盘是一种输入 UI 能力,不应直接等同于可信输入、安全键盘或完整的数据安全方案。

HarmonyOS 官方自定义键盘指南确实提到,自定义键盘可以用于增强敏感信息输入场景的安全性,同时官方还专门给出了“自定义键盘实现防截屏”的指导入口。

因此,涉及真正敏感数据时,建议继续按官方安全能力核对防截屏、窗口隐私模式及其对应权限和适用条件,而不是因为换成了自定义数字键盘,就认为敏感信息保护已经完成。

本文的金额键盘本身不需要在 module.json5 中增加额外权限,因此没有为了展示配置而虚构 requestPermissions。如果后续接入独立的隐私窗口或其他安全能力,应以对应 API 文档列出的权限要求为准。

十一、实际项目中建议这样排查

如果自定义金额键盘表现不符合预期,可以按这个顺序检查:

  1. 先看版本。 确认当前 SDK、compileSdkVersion、targetSdkVersion 与项目实际运行设备的 API 能力是否匹配,HarmonyOS 7 当前对应 API 26.0.0。
  2. 再看 Builder。 customKeyboard 使用的 UI 构建函数是否正确通过 @Builder 描述。
  3. 检查绑定。 TextInput 是否真正执行了 .customKeyboard(...),而不是在获焦以后才临时改变绑定状态。
  4. 检查光标。 修改绑定文本后有没有根据 onTextSelectionChange 保存的位置重新调用 caretPosition()。
  5. 检查选区。 删除和插入是否同时考虑 selectionStart 与 selectionEnd。
  6. 检查金额规则。 小数位数、整数位数、重复小数点等属于业务规则,不要误以为系统会自动完成。
  7. 检查避让。 supportAvoidance 只能解决基础遮挡;页面底部其他内容仍被挡住时,再处理键盘高度与页面布局。
  8. 最后看焦点时序。 动态切换系统键盘和自定义键盘时,重点检查状态更新和请求焦点是否发生在正确的渲染时机。

开发经验总结

金额输入的自定义键盘真正有价值的地方,并不是“自己画了十几个按钮”,而是把输入行为收缩到业务真正需要的范围内。

实现时可以抓住四个核心关系:

customKeyboard 负责替换键盘,状态变量负责保存金额,onTextSelectionChange + caretPosition() 负责维护编辑位置,stopEditing() 负责结束编辑。

页面避让则要单独考虑。简单页面使用 supportAvoidance 已经能覆盖输入框不被遮挡的基本需求;复杂支付页如果底部还有关键操作区,就应该继续监听键盘区域变化并调整页面布局。

最后还有一点比较重要:自定义键盘并不会自动获得“安全键盘”的全部语义。进入银行卡、密码或其他敏感数据场景后,应继续沿着 HarmonyOS 官方提供的隐私与安全能力做完整设计,而不是把输入 UI 和安全能力混为一谈。

如果你的金额输入框目前只考虑了“末尾追加数字”,可以重点试一下把光标移到金额中间,再执行插入、删除和选区替换。自定义键盘真正容易出现问题的地方,往往就在这里。

如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐