HarmonyOS 7 文本选区交互:点到别处不消失,setTextSelectionClearPolicy 该怎么用

在阅读页长按选了一段话,接着点旁边的留白,蓝色选区和两个手柄还在。换到临时信息面板,又希望点到外面就结束选中。两种需求都合理,但如果每个页面随手设置一次,返回阅读页后很容易留下另一种交互。

HarmonyOS 7 的 API 26 新增 setTextSelectionClearPolicy,给出了明确的文本选择清除策略。这里先做一个能对照观察的页面,再把临时策略的进入、退出管理起来。重点不是强制所有页面都清除,而是让一处设置的影响有明确归属。

先纠正一个容易看错的词:外部

官方枚举说的是“触摸到文本组件外部”,不是“点到当前选区以外的任何地方”。一段长文本中没有被选中的字,仍可能位于同一个 Text 组件内。测试时把这两种位置混在一起,就会把正确结果当成接口失效。

策略外部触摸后的文本选择默认情况
KEEP_SELECTED_TEXT_ON_EXTERNAL_TOUCH保留选中与手柄未设置时采用此策略
CLEAR_SELECTED_TEXT_ON_EXTERNAL_TOUCH清除选中与手柄需要显式设置

清除选中不等于删除正文,也不是清空剪贴板,更不是一条通用的焦点重置命令。已经复制出去的内容不会因为换了这个策略就获得额外的安全保护。如果需求是敏感信息不可复制,需要另行设计复制权限和内容呈现,不应借用一个交互策略来实现。

本文核对的 UIContext 文档更新于2026年9月23日,核对日期为9月27日。接口及枚举从26.0.0开始支持,仅用于Stage模型,系统能力为 SystemCapability.ArkUI.ArkUI.Full;API26同时支持元服务。UIContext模块本身更早存在,不能把模块的起始版本当作这个方法的起始版本。

文本选区清除与应用策略所有者

图中下半部分的策略栈是应用自己实现的管理方式,不是系统提供了自动入栈、出栈接口。恢复 KEEP 仅恢复后续交互策略,不会把已经清掉的选区重新选回来。

案例一:把“看起来没生效”变成可重复的对照实验

先不接业务,把下面页面放进API26 Stage工程的页面目录并注册到页面配置。两个按钮只切换策略;中间Text可以选择,底部留白区则明确位于Text组件之外。切换策略后重新长按选择文字,再触摸留白区,不要拿按钮点击前遗留的状态判断结果。

import { TextSelectionClearPolicy } from '@kit.ArkUI';

@Entry
@Component
struct SelectionLab {
  @State policyName: string = 'KEEP';

  private apply(clear: boolean): void {
    this.getUIContext().setTextSelectionClearPolicy(clear
      ? TextSelectionClearPolicy.CLEAR_SELECTED_TEXT_ON_EXTERNAL_TOUCH
      : TextSelectionClearPolicy.KEEP_SELECTED_TEXT_ON_EXTERNAL_TOUCH);
    this.policyName = clear ? 'CLEAR' : 'KEEP';
  }

  build() {
    Column({ space: 18 }) {
      Text('当前策略:' + this.policyName).fontSize(18)
      Row({ space: 12 }) {
        Button('保留选区').onClick(() => this.apply(false))
        Button('外部触摸清除').onClick(() => this.apply(true))
      }
      Text('这段文字可用于选择实验。先长按选中几个字,再触摸下面的留白区域。')
        .fontSize(22)
        .copyOption(CopyOptions.LocalDevice)
        .width('100%')
        .padding(12)
        .backgroundColor('#EDF3FF')
      Column() {
        Text('文本组件外部的测试区域').fontSize(14)
      }
      .width('100%')
      .height(180)
      .backgroundColor('#F2F2F2')
    }
    .padding(24)
    .width('100%')
    .height('100%')
  }
}

这里的 LocalDevice 来自 Text 的复制配置,作用是让Text具备相应复制选择交互,不是清除策略本身。不要把一个未配置可选择能力、根本无法建立选区的Text当成CLEAR成功案例。

按以下顺序验证,一次只改变一个变量:

  1. 初次进入,长按建立选区,触摸下方留白。观察默认KEEP。
  2. 点击“外部触摸清除”,重新建立选区,触摸同一留白。观察CLEAR。
  3. 再设置KEEP,再次建立选区,重复相同操作。
  4. 对比触摸Text内部未选中的字与Text外部留白;分别记录,不合并成一个结论。

观察点包括选中背景、两端手柄、正文内容,以及触摸位置是否真的越过组件边界。正文前后应相同。为了定位布局问题,可以给组件临时添加明显背景,但不要添加会吞掉触摸事件的业务手势后才做第一次测试。

此处提供的是按官方签名编写的ArkUI接入代码;当前验证环境没有API26 SDK和可连接真机,未完成该页面的API26编译及设备交互验证。官方UIContext参考也提示效果以真机为准,不能把预览器无效果直接判为平台缺陷。下文实际执行的是公开策略模型及断言,不是这一段UI代码的设备测试。

案例二:临时面板关了,阅读页为什么仍然自动清除

假设阅读页需要KEEP,临时面板需要CLEAR。最直接的代码是在进入面板时设CLEAR,退出时设KEEP。单层场景能工作,但以后又出现第二个面板,就容易出现较早面板退出时覆盖较新面板策略的情况。

不能只把“退出恢复默认值”写在每个页面的消失回调里。默认值不一定等于上一个活动场景的要求,而且组件存在也不等于页面当前可见。缓存页面、Navigation目的页、弹层和子窗的可见生命周期需要由应用入口协调。

我会选一个很小的所有者列表:每次进入返回唯一token;退出仅移除自己的token;最后进入且仍有效的所有者决定当前策略。基础策略由应用明确传入,而不是读取一个本文没有验证存在的系统getter。

export type SelectionPolicy = 'KEEP' | 'CLEAR';
interface PolicyOwner {
  token: number;
  name: string;
  policy: SelectionPolicy;
}

export class SelectionPolicyScope {
  private nextToken: number = 0;
  private owners: PolicyOwner[] = [];
  private base: SelectionPolicy;
  private apply: (policy: SelectionPolicy) => void;

  constructor(
    base: SelectionPolicy,
    apply: (policy: SelectionPolicy) => void
  ) {
    this.base = base;
    this.apply = apply;
    this.apply(base);
  }

  current(): SelectionPolicy {
    return this.owners.length === 0
      ? this.base : this.owners[this.owners.length - 1].policy;
  }

  enter(name: string, policy: SelectionPolicy): number {
    const owner: PolicyOwner = { token: ++this.nextToken, name, policy };
    this.owners.push(owner);
    try {
      this.apply(this.current());
    } catch (error) {
      this.owners.pop();
      throw error;
    }
    return owner.token;
  }

  leave(token: number): boolean {
    const index = this.owners.findIndex(owner => owner.token === token);
    if (index < 0) return false;
    const removed: PolicyOwner = this.owners[index];
    this.owners.splice(index, 1);
    try {
      this.apply(this.current());
    } catch (error) {
      this.owners.splice(index, 0, removed);
      throw error;
    }
    return true;
  }
}

enter/leave是本例自定义方法,与系统接口没有同名对应关系。列表按“最近进入优先”工作,是这里选择的产品规则;若应用要求显式优先级,应该改变规则并补测试,不能暗示框架原本就有这个优先级。

应用只在一个位置把字符串策略映射到平台枚举:

import { UIContext, TextSelectionClearPolicy } from '@kit.ArkUI';
import { SelectionPolicyScope, SelectionPolicy } from './SelectionPolicyScope';

export function createSelectionScope(ui: UIContext): SelectionPolicyScope {
  return new SelectionPolicyScope('KEEP', (policy: SelectionPolicy): void => {
    ui.setTextSelectionClearPolicy(policy === 'KEEP'
      ? TextSelectionClearPolicy.KEEP_SELECTED_TEXT_ON_EXTERNAL_TOUCH
      : TextSelectionClearPolicy.CLEAR_SELECTED_TEXT_ON_EXTERNAL_TOUCH);
  });
}

一个UIContext对应一份这样的管理对象,由对应窗口或页面容器持有。不要把第一个窗口的UIContext存成跨窗口全局单例,再拿去改第二个窗口。调用示意是进入可见阅读场景时enter,打开面板时再enter,真正结束该场景时leave;这些动作接到哪一种生命周期,取决于实际使用的路由和弹层,而不是统一塞进aboutToDisappear就算结束。

用两种退出顺序检查是否相互覆盖

下面代码与上面的模型放在同一TypeScript文件可以直接执行。第一组覆盖正常嵌套退出;第二组故意让较早所有者先退出,并重复退出旧token。它们验证的是应用管理规则,不能替代真机文本选择行为测试。

function expectPolicy(actual: SelectionPolicy, expected: SelectionPolicy): void {
  if (actual !== expected) throw new Error(actual + ' != ' + expected);
}
let applied: SelectionPolicy = 'KEEP';
const scope = new SelectionPolicyScope('KEEP', policy => { applied = policy; });
const reader = scope.enter('reader', 'KEEP');
const panel = scope.enter('panel', 'CLEAR');
expectPolicy(applied, 'CLEAR');
scope.leave(panel);
expectPolicy(applied, 'KEEP');
scope.leave(reader);
expectPolicy(applied, 'KEEP');

const oldPanel = scope.enter('old-panel', 'CLEAR');
const newReader = scope.enter('new-reader', 'KEEP');
scope.leave(oldPanel);
expectPolicy(applied, 'KEEP');
if (scope.leave(oldPanel)) throw new Error('old token must not be reused');
scope.leave(newReader);
expectPolicy(applied, 'KEEP');
console.log('nested and out-of-order policy cases passed');

再增加一次平台适配器抛错的测试,避免模型声称进入成功而平台设置失败:

let fail: boolean = false;
const failureScope = new SelectionPolicyScope('KEEP', () => {
  if (fail) throw new Error('simulated adapter failure');
});
fail = true;
let rejected: boolean = false;
try { failureScope.enter('failed-panel', 'CLEAR'); }
catch { rejected = true; }
if (!rejected) throw new Error('failure was swallowed');
expectPolicy(failureScope.current(), 'KEEP');
console.log('adapter failure rollback passed');

这是内存模型回退,不是承诺系统调用出错后一定没有任何副作用。适配器异常仍应交由上层记录并处理;不要用空catch把错误藏起来,也不要无限重试一个已经失效的UIContext。

简单设置和统一管理,什么时候选哪一种

只有一个固定阅读页、策略从不切换时,直接设置一次就够。引入所有者列表反而增加维护点。出现多个会临时改变策略的场景后,统一入口的价值才体现出来:查日志能知道当前是谁持有CLEAR,而不是在十几个页面里搜索最后一次调用。

方案适用情况要承担的成本
固定场景直接设置单页面、没有临时覆盖后续新增场景时重新评估
各页面退出时写回KEEP严格单层且基础需求永远KEEP嵌套与乱序退出容易覆盖其他场景
每UIContext一个所有者管理器多场景临时切换、存在嵌套必须准确对接可见生命周期并释放token

不要为了让选区消失去重建整棵Text节点,也不要把触摸事件全局拦截来模拟CLEAR。前者增加不必要的状态变化,后者可能影响滚动、链接和无障碍交互。官方明确提供了这一策略时,优先采用它,再把应用侧的归属管理好。

接入后我会保留四项回归:KEEP与CLEAR分别触摸组件外部;嵌套场景按两种顺序退出;两个窗口分别改变策略;页面缓存后返回。每一项都把触摸位置、当前UIContext、持有者和期望策略记在一起。问题再发生时,才能区分接口行为、布局边界和应用状态覆盖,而不是继续增加一次“恢复默认”的调用。

官方参考

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐