在鸿蒙应用开发中,文本交互的深度与流畅度直接影响用户体验。以往,普通Text组件的文本选区依赖于用户长按或双击手势,开发者无法通过代码主动控制高亮区域,这限制了AI划词、搜索高亮、语音同步标注等创新功能的实现。HarmonyOS NEXT 6.1(API 23)对ArkUI的TextController进行了里程碑式升级,新增了setTextSelection()接口,使开发者能够通过代码动态指定文本索引区间,触发原生高亮框选,并支持系统菜单联动。本文将结合实战,深入解析该接口的能力、使用方法和常见避坑点。
一、能力与原理
在ArkUI的文本渲染管道中,Text组件开启复制能力(如设置copyOption)后,底层会激活选区高亮渲染层。API 23之前,该层完全由系统手势状态机控制,应用层无法介入。setTextSelection()的出现打破了这一封闭链路:调用时,指令绕过手势层,直接将起始索引(selectionStart)和结束索引(selectionEnd)注入底层状态机,文本测量层(Paragraph)立即计算字符物理坐标,在无需重排的前提下局部叠加蓝色高亮色块,并可根据选项决定是否弹出系统级复制、全选菜单。
二、API定义与约束
TextController上的setTextSelection()方法原型如下:- setTextSelection(
- selectionStart: number | undefined,
- selectionEnd: number | undefined,
- options?: SelectionOptions
- ): void;
复制代码 参数说明:
- selectionStart:起始索引,范围[0, +∞)。负数或undefined强制按0处理。
- selectionEnd:结束索引,范围[0, +∞)。负数或undefined强制按0处理。
- options:可选,SelectionOptions对象,其menuPolicy字段支持SHOW(弹出菜单)、HIDE(隐藏菜单)、DEFAULT(默认行为)。
生效前置条件:
- 必须将Text组件的copyOption设置为非None值(如CopyOptions.InApp或CopyOptions.LocalDevice),否则调用静默失效。
- 不能与跑马灯模式(TextOverflow.MARQUEE)同时使用,否则不生效。
边界规则:
- 当selectionStart ≥ selectionEnd时,视为无效选区,无任何选中效果。
- 索引超出文本总字符数(textSize)时,自动截断为textSize边界。
- 若选中索引对应的字符因截断(如textOverflow)而不可见,则高亮不生效。
- 在PC/2in1设备上,即使设置MenuPolicy.SHOW也不会主动弹出菜单,以符合桌面交互习惯。
- Emoji字符会作为整体被完整选中。
三、实战代码落地
下面是一个完整的Demo示例,展示了如何在ArkUI中使用TextController实现动态选区高亮,并提供索引调整面板和诊断日志。- import { router } from '@kit.ArkUI';
- @Entry
- @Component
- struct TextSelectionDetail {
- private textController: TextController = new TextController();
- @State selectionStartInput: number = 0;
- @State selectionEndInput: number = 10;
- @State selectionLogs: string = '输入起始和结束索引,点击按钮触发动态选择高亮';
- private testTextValue: string = 'HarmonyOS NEXT 极客排版与动态选区高亮技术(API 23)';
- build() {
- Column() {
- // 导航栏
- Row() {
- Button('返回')
- .onClick(() => { router.back(); })
- .backgroundColor('#F5A623').fontColor('#000000').height(36).margin({ right: 16 })
- Text('TextController 动态选区高亮实战')
- .fontColor('#FFFFFF').fontSize(18).fontWeight(FontWeight.Bold)
- }
- .width('100%').height(56).backgroundColor('#111111').padding({ left: 16, right: 16 })
- Column() {
- // 文本展示区
- Column() {
- Text('【动态选区测试 Text 组件】')
- .fontSize(14).fontColor('#F5A623').fontWeight(FontWeight.Bold).margin({ bottom: 12 })
- Text(this.testTextValue, { controller: this.textController })
- .fontSize(16).fontColor('#FFFFFF').lineHeight(24)
- .copyOption(CopyOptions.InApp) // 核心:必须设置非None
- .width('100%').padding(12).backgroundColor('#111111').borderRadius(8).margin({ bottom: 8 })
- Text(`文本总字符数 (textSize):${this.testTextValue.length}`)
- .fontSize(12).fontColor('#888888')
- }
- .width('100%').padding(16).backgroundColor('#161616').borderRadius(10)
- .border({ width: 1, color: '#2C2C2C' }).margin({ bottom: 20 })
- // 索引控制面板
- Column() {
- Text('【选区索引精确控制器】')
- .fontSize(14).fontColor('#F5A623').fontWeight(FontWeight.Bold).margin({ bottom: 12 })
- Row({ space: 12 }) {
- Column() {
- Text('起始位置 (Start)').fontSize(12).fontColor('#A0A0A0').margin({ bottom: 6 })
- TextInput({ text: this.selectionStartInput.toString() })
- .width('100%').height(40).type(InputType.Number)
- .backgroundColor('#1A1A1A').fontColor('#FFFFFF')
- .onChange((value: string) => { this.selectionStartInput = parseInt(value) || 0; })
- }.flexGrow(1)
- Column() {
- Text('结束位置 (End)').fontSize(12).fontColor('#A0A0A0').margin({ bottom: 6 })
- TextInput({ text: this.selectionEndInput.toString() })
- .width('100%').height(40).type(InputType.Number)
- .backgroundColor('#1A1A1A').fontColor('#FFFFFF')
- .onChange((value: string) => { this.selectionEndInput = parseInt(value) || 0; })
- }.flexGrow(1)
- }
- .width('100%').margin({ bottom: 16 })
- Row({ space: 10 }) {
- Button('触发 setTextSelection')
- .onClick(() => {
- try {
- this.textController.setTextSelection(
- this.selectionStartInput,
- this.selectionEndInput,
- { menuPolicy: MenuPolicy.SHOW }
- );
- this.selectionLogs = `执行选区高亮:[${this.selectionStartInput}, ${this.selectionEndInput}] 成功触发`;
- } catch (err) {
- this.selectionLogs = `执行失败:${JSON.stringify(err)}`;
- }
- })
- .backgroundColor('#E65100').fontColor('#FFFFFF').height(40).flexGrow(1)
- Button('清除选区')
- .onClick(() => {
- this.textController.setTextSelection(0, 0);
- this.selectionLogs = '选区已被重置清空';
- })
- .backgroundColor('#2C2C2C').fontColor('#FFFFFF').height(40)
- }
- .width('100%').margin({ bottom: 12 })
- Text(this.selectionLogs)
- .fontSize(12).fontColor('#888888').lineHeight(16).width('100%')
- }
- .width('100%').padding(16).backgroundColor('#161616').borderRadius(10)
- .border({ width: 1, color: '#2C2C2C' })
- }
- .width('100%').flexGrow(1).padding(16).backgroundColor('#000000')
- }
- .height('100%').width('100%').backgroundColor('#000000')
- }
- }
复制代码
四、运行效果与性能
在实际设备测试中,设置起始索引0、结束索引10并触发后,文本“HarmonyOS ”立即被蓝色高亮覆盖,同时弹出系统原生复制/全选菜单。即使以30Hz频率快速触发,CPU占用率仍维持在个位数,无任何卡顿或重排抖动。索引超出文本长度时,底层自动截断至边界,选区顺滑贴合末尾字符。
五、避坑指南
1. CopyOptions.None导致静默失效:如果忘记设置copyOption或错误设为None,setTextSelection调用后无任何视觉反馈。务必显式设置:- .copyOption(CopyOptions.InApp) // 或 CopyOptions.LocalDevice
复制代码 2. 不可见截断区域导致选择哑弹:当Text组件因尺寸限制或textOverflow截断导致部分字符不可见时,选中不可见区域不会触发高亮。如需高亮超出边缘的文本,应关闭截断或使用滚动容器确保所有字符在排版树上可见。
六、总结
setTextSelection()为鸿蒙应用带来了原生级别的动态选区控制能力,彻底解放了长按手势的束缚。通过将高亮渲染下沉至底层文本引擎,实现了零重排的高性能交互,并完美兼容系统级菜单。该接口非常适合AI划词、动态标注、语音朗读同步高亮、全文搜索关键词高亮等场景,建议开发者在API 23及以上版本的项目中积极采用。 |