ReviewBoard 在折叠屏连接鼠标后看起来很顺手:指针移到任务卡上,编辑、归档、更多操作会自然浮现。可一旦拔掉鼠标改用触控,这三个按钮就像从页面上消失了;接入键盘后,焦点虽然能移动,视觉高亮又和悬停态打架。这个问题不是加一个
onHover就结束,而是要让鼠标、键盘和触控共享同一套操作语义。
一、鼠标用户看得到,手指用户找不到
ReviewBoard 是一张审核任务看板。展开态窗口宽度为912 vp,任务卡RV-1027右上角有编辑、归档和更多三个轻量操作。设计稿为了减少干扰,默认隐藏按钮,只有鼠标悬停时显示。大屏演示很漂亮,真机测试却马上暴露问题:半折叠后用户改用触摸,没有 Hover,操作入口一直隐藏;用 Tab 键进入卡片时,焦点落在卡片上,但按钮仍不可见。
第一次修复很直接:触摸按下时也把hovered=true。结果手指离开后悬停态没有可靠的退出时机,卡片长期保持高亮;滚动列表时,经过的卡片还会一个个“点亮”。这让我意识到,Hover 只是输入信号,不应该直接充当业务状态。
最终我把交互拆成四个瞬时事实:hovered、focused、pressed、menuOpened,再由它们推导actionsVisible。触控模式还提供常驻“更多”按钮,不依赖任何悬停动作。本文固定的验收记录为PARITY-1002,页面ReviewBoard,任务RV-1027,覆盖POINTER、KEYBOARD、TOUCH三种模式,共 18 个用例,最终不可达操作为 0,状态为PARITY_PASSED。
二、输入来源和页面形态是两条轴
窗口变宽不等于一定连接鼠标,连接鼠标也不等于用户不会触摸。旧代码用width >= 840推断“桌面模式”,把输入能力和布局尺寸绑在一起;折叠屏恰恰会打破这个假设。
当前问题是分别记录布局和最近有效输入,再计算展示策略。下面的状态模型不把 Hover 写进业务对象,也不在build()中修改状态。
exporttypeLayoutMode='COMPACT'|'EXPANDED'exporttypeInputMode='POINTER'|'KEYBOARD'|'TOUCH'@ObservedV2exportclassInteractionContext{@Tracelayout:LayoutMode='COMPACT'@Traceinput:InputMode='TOUCH'@TracewidthVp:number=0updateWidth(widthVp:number):void{this.widthVp=widthVpconstnext:LayoutMode=widthVp>=840?'EXPANDED':'COMPACT'if(next!==this.layout)this.layout=next}markInput(input:InputMode):void{this.input=input}shouldKeepMoreVisible():boolean{returnthis.input==='TOUCH'||this.layout==='COMPACT'}}updateWidth()只负责布局,markInput()只记录最近一次明确输入。展开到912 vp后,页面可以使用横向任务卡,但如果最近输入仍是触控,“更多”入口照样常驻。鼠标移动或键盘按键事件到来时,输入模式再切换,不需要重建页面。
正式项目可通过当前 SDK 支持的输入事件来源进一步区分鼠标、触控板和触摸屏;Demo 为了可复测,用统一的页面事件适配器上报。窗口监听和输入监听应在页面出现时注册、退场时注销,避免旧页面继续改变全局模式。宽度阈值还要加迟滞,防止自由窗口停在 839/840 vp 附近反复重排。
三、操作可见性必须是推导值
每张任务卡最初维护一个showActions,Hover、Focus、点击菜单都可以改它。多个事件交错后,谁最后写值谁获胜,导致菜单已经打开,鼠标移出却把按钮和菜单锚点一起隐藏。
当前问题是把事实状态保留下来,由一个只读规则计算最终可见性。这样每个事件只修改自己负责的字段。
@ObservedV2exportclassCardInteractionState{@Tracehovered:boolean=false@Tracefocused:boolean=false@Tracepressed:boolean=false@TracemenuOpened:boolean=falseactionsVisible(context:InteractionContext):boolean{returnthis.hovered||this.focused||this.menuOpened||context.shouldKeepMoreVisible()}visualRaised():boolean{returnthis.hovered||this.focused||this.pressed}resetTransient():void{this.hovered=falsethis.focused=falsethis.pressed=false}}当鼠标移出时只把hovered设为 false;如果菜单仍然打开,actionsVisible()依旧为 true。键盘焦点进入时focused=true,按钮立即出现;触控模式即使四个事实都是 false,shouldKeepMoreVisible()仍保证一个可达入口。
状态跟随卡片组件实例,列表复用时要按稳定任务 ID 重置,不能把上一张卡的 Hover 带到下一张。页面形态从展开变紧凑时调用resetTransient(),防止已经不存在的指针位置继续抬高卡片;menuOpened不在其中,因为菜单是否关闭应由弹层生命周期决定,而不是布局变化偷偷决定。
四、同一个动作必须有三条等价路径
“按钮出现了”并不代表等价交互已经完成。鼠标可以点击图标,键盘用户需要 Enter/Space,触控用户需要足够大的命中区。更关键的是,三个入口必须调用同一个业务命令,否则埋点、确认弹窗和权限判断会分叉。
当前问题是把动作收敛到executeAction(),输入事件只负责把意图翻译为命令。
exporttypeReviewAction='EDIT'|'ARCHIVE'|'MORE'exportclassReviewActionCoordinator{privaterunning:Set<string>=newSet()asyncexecuteAction(taskId:string,action:ReviewAction):Promise<void>{constkey=`${taskId}:${action}`if(this.running.has(key))returnthis.running.add(key)try{if(action==='ARCHIVE'){awaitthis.confirmAndArchive(taskId)}elseif(action==='EDIT'){RouterService.openEditor(taskId)}else{OverlayService.openMore(taskId)}}finally{this.running.delete(key)}}}鼠标点击、键盘激活和触控点击都调用同一个方法。running以任务和动作组合做门闩,避免鼠标双击或键盘长按重复归档。动作完成后必须在finally中释放,否则异常会让入口永久失效。
Demo 的归档确认使用稳定的任务 ID,不读取列表当前索引。正式项目还要在服务端做幂等,页面门闩只能挡住同一实例内的重复操作。若菜单跨窗口显示,OverlayService应跟随当前 UIContext 创建并在页面离开时关闭,不能作为全局单例长期持有窗口上下文。
五、ArkUI 卡片把 Hover、Focus 和触控分开接线
状态模型和协调器准备好后,组件只负责事件绑定。视觉上,Hover 与 Focus 都抬高卡片,但焦点需要清晰描边;触控按下只提供短暂反馈,不让悬停态残留。
当前问题是让不同事件修改各自事实,并始终保留可访问名称。
@ComponentV2struct ReviewCard{@ParamtaskId:string=''@Paramtitle:string=''@Paramcontext:InteractionContext=newInteractionContext()@Localstate:CardInteractionState=newCardInteractionState()build(){Stack({alignContent:Alignment.TopEnd}){this.CardBody()if(this.state.actionsVisible(this.context)){this.ActionBar()}}.focusable(true).onHover((value:boolean)=>{this.context.markInput('POINTER')this.state.hovered=value}).onFocus(()=>{this.context.markInput('KEYBOARD')this.state.focused=true}).onBlur(()=>{this.state.focused=false}).gesture(TapGesture().onAction(()=>{this.context.markInput('TOUCH')})).accessibilityText(`${this.title},审核任务${this.taskId}`)}}onHover不再直接显示按钮,它只更新hovered;可见性由规则推导。焦点进入后输入模式切为 KEYBOARD,退出只清理卡片焦点。Tap 只标记触控来源,不冒充 Hover。操作按钮本身还要分别设置可读语义和合理命中尺寸,不能只依靠整卡 accessibilityText。
页面销毁时组件状态自然回收,但若外部 Input Kit 监听注册在 ViewModel 中,仍需显式取消。重复创建监听会让一次移动触发多次模式切换。不同 API 版本的输入来源字段可能变化,应通过适配层封装,不要把系统事件类型散落在每张卡片里。
六、调试不看动画,看可达性矩阵
工程目录分为pages/ReviewBoardPage.ets、components/ReviewCard.ets、model/InteractionContext.ets、model/CardInteractionState.ets、service/ReviewActionCoordinator.ets和utils/InputModeAdapter.ets。我没有把所有逻辑写进卡片,因为输入模式属于页面上下文,动作幂等属于业务协调器。
HiLog 固定输出:PARITY-1002 width=912vp layout=EXPANDED、Input POINTER -> KEYBOARD -> TOUCH、RV-1027 actionsVisible=true、Cases=18 unreachable=0 state=PARITY_PASSED。这几行比“Hover 动画正常”更接近验收目标。
DevEco Studio 图里,右侧模拟器显示RV-1027的操作条,中间代码停在actionsVisible(),底部日志显示三种输入模式都通过。红色标注只指向常驻“更多”入口和unreachable=0。
七、折回触控后,入口仍然在
最终手机图模拟窗口从912 vp折回684 vp,布局变为COMPACT,最近输入为TOUCH。Hover 与 Focus 都已经清零,但任务卡右上角仍有“更多”,状态卡显示PARITY_PASSED,18 个用例全部完成。
这次修复没有让触控界面永久铺开三个按钮,而是保留一个清晰入口;键盘用户获得焦点描边,鼠标用户继续享受悬停快捷操作。三条路径视觉可以不同,业务动作和可达性不能不同。
八、结语
多形态适配不只是窗口宽度变化,也包括输入方式随时变化。把 Hover 当成装饰没问题,把它当成唯一入口就会制造功能缺口。ReviewBoard 最终留下的规则很简单:布局和输入分轴记录,瞬时事实不互相覆盖,操作命令只保留一个执行入口。
实际项目还要把屏幕阅读、快捷键提示和触控命中区纳入同一套验收矩阵。只有鼠标、键盘、手指都能完成同一件事,折叠屏上的“大屏体验”才不是只对演示设备成立。