HarmonyOS 手势仲裁:棋格练习页如何区分单击、双击和长按【共创稿事节】
同一个棋格同时支持单击选择、双击标记和长按查看说明,看起来只是多挂三个回调。真正需要先确定的却是动作关系:双击是否允许顺便选择?长按结束后能不能再算一次单击?如果这些问题没有产品约定,代码评审只能讨论回调有没有执行,无法判断执行结果究竟对不对。

先约定一次输入只能完成哪件事
BoardGesture 只做一个标为 D4 的棋格练习区。单击使它进入选中状态,双击切换标记,长按展示一条说明。三个动作都不提交比赛结果,也不包含完整棋局规则。将范围收紧到一个区域,可以把触摸识别和业务更新之间的关系看清楚,避免复杂棋盘的坐标计算掩盖手势冲突。
本例的约定是互斥:一次被识别的输入只触发其中一种业务动作。双击标记时不同时执行选择,长按说明时不增加单击次数。选择与标记本身仍是两个独立状态,用户可以先选中,再通过下一次双击标记。互斥描述的是本轮识别,不是禁止一个棋格同时拥有两种业务属性。
这项约定决定使用 GestureGroup 的 Exclusive 模式。Parallel 适合允许多个识别结果同时成立的场景,不能因为它让双击回调终于出现,就直接拿来修复当前问题。Sequence 则表达按顺序完成一串手势,例如先长按再拖动,也不符合三个动作择一的需求。模式选择应从业务结果出发。
本例采用 API 12 及以上 Stage 工程,使用 ArkUI 内置手势,不需要额外权限和第三方依赖。TapGesture 的次数参数写作 count;当前命名与所选版本一致。不要把其他平台上的参数名照搬过来,再根据界面偶尔响应来推断配置已经生效。
评审时可以把接受标准写成三句话:一次符合条件的双击只增加标记次数;一次长按只增加说明次数;一次普通单击只增加选择次数。这样的标准比“支持三种手势”更具体,也便于复现争议。每个回调都能独立触发,不代表三个回调组合以后仍然符合约定。
将较具体的点击放在前面
单击与双击共享第一下触摸,识别器需要决定是否继续等待第二下。官方组合手势说明明确指出,在互斥组内先声明单击会使它抢先完成,双击因此无法识别。需要双击优先时,应把双击放在单击之前。这个顺序是功能的一部分,不能让代码格式化或重构时随意交换。
双击优先也有可见代价。官方 TapGesture 文档说明,这种组合下单击会有三百毫秒等待。它用于判断是否出现第二下点击,不是页面渲染性能下降的测量结果。若产品不能接受单击等待,应重新安排入口,例如把标记放到独立按钮,而不是一边要求保留双击,一边要求第一下立即产生不可撤回的业务动作。
长按明确设置六百毫秒,并关闭重复触发。它表示达到规定持续时间后触发说明,不表示用户按住多久就要不断增加次数。代码把长按和两种点击放在同一个互斥组,由框架识别和仲裁,不再额外添加一个按下后启动的定时器。两套机制同时判断同一次触摸,很容易产生相互矛盾的结果。
按住与滑动也不能混淆。平台识别包含移动范围与触点条件,用户明显移开手指时,不应靠“已经经过六百毫秒”强行算作有效长按。这里选择非拖拽的 Column 作为承载区域,避免直接把长按绑定到带默认拖拽行为的文本或图片上,降低示例中不相关的竞争因素。
本页的验证围绕单指操作展开,并未宣称严格排除所有多指输入。手势参数中的手指数与具体识别限制需要按目标版本解释,不能把默认值直接理解为多指防护开关。如果产品存在明确的多指限制,应单独核对可用配置和设备行为,再扩展验收范围。
完整页面把结果写在明处
保留新建工程生成的 Ability 和页面路由,将 pages/Index.ets 替换为下面代码即可得到完整调用入口。三个业务方法各自更新自己的计数与状态,识别配置集中在棋格区域。重置按钮清空当前练习记录,使每次验证都可以从同一个条件开始。
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct Index {
@State selected: boolean = false;
@State marked: boolean = false;
@State selectCount: number = 0;
@State markCount: number = 0;
@State inspectCount: number = 0;
@State message: string = '等待在 D4 棋格上操作';
selectCell(): void {
this.selected = true;
this.selectCount += 1;
this.message = '已选择 D4';
}
toggleMark(): void {
this.marked = !this.marked;
this.markCount += 1;
this.message = this.marked ? '已标记 D4' : '已取消 D4 标记';
}
inspectCell(): void {
this.inspectCount += 1;
this.message = 'D4 是本页的手势练习棋格';
}
resetPractice(): void {
this.selected = false;
this.marked = false;
this.selectCount = 0;
this.markCount = 0;
this.inspectCount = 0;
this.message = '等待在 D4 棋格上操作';
}
build() {
Scroll() {
Column({ space: 11 }) {
Text('棋格手势练习').fontSize(28).fontWeight(FontWeight.Bold)
Text('一个区域,三种明确动作').fontSize(16).fontColor('#64748B')
Column({ space: 8 }) {
Text('D4').fontSize(56).fontWeight(FontWeight.Bold).fontColor('#28594E')
Text(this.marked ? '已标记' : '未标记').fontSize(17).fontColor('#28594E')
}.width('100%').height(180).justifyContent(FlexAlign.Center)
.backgroundColor(this.selected ? '#C5E4D8' : '#E4F0EB')
.borderRadius(16)
.border({ width: this.marked ? 3 : 1, color: this.marked ? '#B78622' : '#C7DDD3' })
.gesture(
GestureGroup(GestureMode.Exclusive,
LongPressGesture({ repeat: false, duration: 600 })
.onAction(() => {
this.inspectCell();
}),
TapGesture({ count: 2 })
.onAction(() => {
this.toggleMark();
}),
TapGesture({ count: 1 })
.onAction(() => {
this.selectCell();
})
)
)
Row() {
Column({ space: 4 }) {
Text(`${this.selectCount}`).fontSize(24)
Text('选择次数').fontSize(14)
}.layoutWeight(1)
Column({ space: 4 }) {
Text(`${this.markCount}`).fontSize(24)
Text('标记次数').fontSize(14)
}.layoutWeight(1)
Column({ space: 4 }) {
Text(`${this.inspectCount}`).fontSize(24)
Text('说明次数').fontSize(14)
}.layoutWeight(1)
}.width('100%').padding(10)
Column({ space: 8 }) {
Text(this.selected ? '棋格已选中' : '棋格未选中').fontSize(17)
Text(this.message).fontSize(16).fontColor('#39675A')
}.padding(16).width('100%').backgroundColor('#FFFFFF').borderRadius(12)
Text('单击选择 · 双击标记 · 长按说明').fontSize(14).fontColor('#64748B')
Button('重置练习').width('100%').height(44).backgroundColor('#35755F')
.onClick(() => { this.resetPractice(); })
Text('长按门槛 600 ms;双击按平台时序识别').fontSize(13).fontColor('#64748B')
}.padding(20).width('100%')
}.height('100%').width('100%').backgroundColor('#F4F8F5')
}
}
计数是识别完成后执行相应方法的次数,不是所有手指按下次数。双击包含两下物理点击,但本页只希望标记次数增加一次。长按持续很久也只希望说明次数增加一次。界面将三个数字同时显示,就是为了让附带触发更容易被发现,而不是用一条不断覆盖的提示掩盖前面已经发生的动作。
标记采用切换,选择采用设置为真,这是刻意不同的业务语义。重复单击仍然记录一次有效选择动作,但不会把棋格取消选中;重复双击则在标记与未标记之间切换。如果将来修改语义,需要同时修改方法、说明文字和验证预期,不应该只替换某个回调里的布尔表达式。
页面没有在棋格的外层再挂一个 onClick。相同区域同时存在组件点击与自定义单击时,必须另行理解其竞争关系,不能假定它们天然是同一个入口。这里的单击只由组合手势中的识别器负责,重置按钮则在区域外使用自己的点击事件,职责清楚也便于后续排查。
三个方法也可以在纯逻辑检查中直接调用,确认计数与布尔值更新符合预期,但这种检查无法证明触摸识别顺序正确。框架是否接受一次实际双击、滚动是否参与竞争,仍要通过目标运行环境的输入路径验证。区分这两类证据,可以避免业务方法测试全部通过后,就宣布手势冲突已经解决。

用动作序列验收,而不是只数回调
第一组从重置开始。单击棋格后留出识别等待,预期选择次数为一,其他为零,状态显示已选中。再次重置后,在平台要求的时间与位置范围内双击,预期只增加一次标记次数,棋格仍未选中。图中的双击结果是依据代码绘制的预期状态,不是触摸设备的实测记录。
第二组验证持续按住。重置后按住超过设定门槛,预期出现棋格说明;继续按住不应持续增加次数,松开后也不应额外增加单击次数。若观察到重复增长,先检查 repeat 是否被改动,或是否还有另一个区域监听同一次动作,再考虑框架识别问题。
第三组验证边界。两次点击间隔过长时,不应硬算成一次双击;两次位置相距太远时,也不满足同一次多击识别条件。文档给出的多击间隔与位置限制属于平台识别规则,页面没有重新实现它们。测试记录要写清操作条件,不能把所有“两下点击”都当作同一种输入。
第四组验证组合后的状态。先单击选择,再在下一次输入中双击标记,预期选中和标记同时保留,而两个计数分别增加。再双击一次,只取消标记,选择状态不变。这组检查能够发现业务方法之间是否意外清空彼此字段,补足单独测试每个手势时看不到的问题。

这些检查也要覆盖页面滚动与触摸取消。当前页面使用 Scroll 保证较小窗口仍能查看全部控件,垂直移动可能由滚动处理;不能把每次没有触发棋格动作都判断为丢事件。测试时应区分稳定点按、明显滑动、移出目标和系统打断,逐个说明希望哪个组件接收输入。
把手势规则作为组件合同维护
手势数量并不是交互质量的指标。一个区域承载过多隐藏动作,会让用户难以发现功能,也会让识别等待越来越难解释。练习页把规则直接写在下方,正式产品则应结合使用频率决定哪些动作值得保留,哪些需要可见入口。核心功能不能只靠用户碰巧猜中双击或长按。
接入完整棋盘时,建议保持识别层只产生明确动作,再由棋局逻辑判断该格是否可选、是否允许标记、是否需要展示说明。这样回放棋谱、键盘操作和自动示例也能调用同一业务方法,而不需要伪造触摸事件。手势负责解释输入,业务负责维护棋局规则,两层可以分别验证。
将来若在识别后增加数据库或网络操作,也不要让输入回调直接承担复杂任务。可以先记录明确的业务意图,再由对应模块处理耗时工作与失败反馈。识别成功只说明输入满足当前规则,无法替后续操作提供成功保证;界面的动作记录和实际业务结果应继续保留各自含义。
共享组件的接口还应记录手势模式、声明顺序和是否重复触发。只暴露三个回调名,却不说明它们能否同时发生,会把仲裁责任重新丢给使用方。重构时重点审查这些规则是否保持,才能避免一次看似普通的代码整理,让用户熟悉的双击悄悄变成两次单击。

这个棋格的价值不在于拥有三种输入,而在于每种输入都有明确结果,每次结果都有可观察证据。当产品决定改变动作关系时,先修改合同与验收序列,再调整模式和回调。让手势配置成为可以讨论的设计决定,比在冲突出现后不断提高某个事件的优先级更容易长期维护。
官方参考
更多推荐
所有评论(0)