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() 中修改状态。

export type LayoutMode = 'COMPACT' | 'EXPANDED'
export type InputMode = 'POINTER' | 'KEYBOARD' | 'TOUCH'

@ObservedV2
export class InteractionContext {
  @Trace layout: LayoutMode = 'COMPACT'
  @Trace input: InputMode = 'TOUCH'
  @Trace widthVp: number = 0

  updateWidth(widthVp: number): void {
    this.widthVp = widthVp
    const next: LayoutMode = widthVp >= 840 ? 'EXPANDED' : 'COMPACT'
    if (next !== this.layout) this.layout = next
  }

  markInput(input: InputMode): void {
    this.input = input
  }

  shouldKeepMoreVisible(): boolean {
    return this.input === 'TOUCH' || this.layout === 'COMPACT'
  }
}

updateWidth() 只负责布局,markInput() 只记录最近一次明确输入。展开到 912 vp 后,页面可以使用横向任务卡,但如果最近输入仍是触控,“更多”入口照样常驻。鼠标移动或键盘按键事件到来时,输入模式再切换,不需要重建页面。

正式项目可通过当前 SDK 支持的输入事件来源进一步区分鼠标、触控板和触摸屏;Demo 为了可复测,用统一的页面事件适配器上报。窗口监听和输入监听应在页面出现时注册、退场时注销,避免旧页面继续改变全局模式。宽度阈值还要加迟滞,防止自由窗口停在 839/840 vp 附近反复重排。

三、操作可见性必须是推导值

每张任务卡最初维护一个 showActions,Hover、Focus、点击菜单都可以改它。多个事件交错后,谁最后写值谁获胜,导致菜单已经打开,鼠标移出却把按钮和菜单锚点一起隐藏。

当前问题是把事实状态保留下来,由一个只读规则计算最终可见性。这样每个事件只修改自己负责的字段。

@ObservedV2
export class CardInteractionState {
  @Trace hovered: boolean = false
  @Trace focused: boolean = false
  @Trace pressed: boolean = false
  @Trace menuOpened: boolean = false

  actionsVisible(context: InteractionContext): boolean {
    return this.hovered || this.focused || this.menuOpened ||
      context.shouldKeepMoreVisible()
  }

  visualRaised(): boolean {
    return this.hovered || this.focused || this.pressed
  }

  resetTransient(): void {
    this.hovered = false
    this.focused = false
    this.pressed = false
  }
}

当鼠标移出时只把 hovered 设为 false;如果菜单仍然打开,actionsVisible() 依旧为 true。键盘焦点进入时 focused=true,按钮立即出现;触控模式即使四个事实都是 false,shouldKeepMoreVisible() 仍保证一个可达入口。

状态跟随卡片组件实例,列表复用时要按稳定任务 ID 重置,不能把上一张卡的 Hover 带到下一张。页面形态从展开变紧凑时调用 resetTransient(),防止已经不存在的指针位置继续抬高卡片;menuOpened 不在其中,因为菜单是否关闭应由弹层生命周期决定,而不是布局变化偷偷决定。

四、同一个动作必须有三条等价路径

“按钮出现了”并不代表等价交互已经完成。鼠标可以点击图标,键盘用户需要 Enter/Space,触控用户需要足够大的命中区。更关键的是,三个入口必须调用同一个业务命令,否则埋点、确认弹窗和权限判断会分叉。

当前问题是把动作收敛到 executeAction(),输入事件只负责把意图翻译为命令。

export type ReviewAction = 'EDIT' | 'ARCHIVE' | 'MORE'

export class ReviewActionCoordinator {
  private running: Set<string> = new Set()

  async executeAction(taskId: string, action: ReviewAction): Promise<void> {
    const key = `${taskId}:${action}`
    if (this.running.has(key)) return
    this.running.add(key)
    try {
      if (action === 'ARCHIVE') {
        await this.confirmAndArchive(taskId)
      } else if (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 都抬高卡片,但焦点需要清晰描边;触控按下只提供短暂反馈,不让悬停态残留。

当前问题是让不同事件修改各自事实,并始终保留可访问名称。

@ComponentV2
struct ReviewCard {
  @Param taskId: string = ''
  @Param title: string = ''
  @Param context: InteractionContext = new InteractionContext()
  @Local state: CardInteractionState = new CardInteractionState()

  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 最终留下的规则很简单:布局和输入分轴记录,瞬时事实不互相覆盖,操作命令只保留一个执行入口。

实际项目还要把屏幕阅读、快捷键提示和触控命中区纳入同一套验收矩阵。只有鼠标、键盘、手指都能完成同一件事,折叠屏上的“大屏体验”才不是只对演示设备成立。

Logo

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

更多推荐