PC端手势设计差异封面图

写了这么多篇手势教程,今天这篇来做个总结性的讨论:PC端的手势交互跟移动端到底有什么不同,做PC端应用的时候该怎么设计手势方案。

这个问题很多人不太重视,觉得"手势嘛,手机上能用的PC上也能用"。坦白讲,这么想会踩不少坑。PC端的输入设备(鼠标、键盘、触控板)跟手机(触摸屏)有本质区别,用户的操作习惯和心理预期也不一样。直接照搬手机的手势设计到PC端,体验大概率会翻车。

输入设备的差异——最核心的区别

PC端手势设计差异概念图

手机上只有一种输入方式:手指触摸屏幕。所有的点击、滑动、捏合、旋转都是手指直接操作。

PC端就不一样了,至少有四种输入设备:

  1. 鼠标——精确的点选和拖拽,但没有多点触控
  2. 键盘——精确的命令输入,但没有空间操作
  3. 触控板——支持多点触控手势(捏合、旋转、滑动)
  4. 触摸屏——有些PC有,但比例不高

这意味着你在PC端设计的每个手势交互,都要考虑"没有触摸屏的用户怎么办"。不能把一个功能只绑定在双指捏合上,因为大量PC用户用的是鼠标,根本做不了双指操作。

Hover效果——PC端独有的交互维度

手机上没有"悬浮"这个概念。手指要么在屏幕上,要么不在。但PC端鼠标可以悬浮在元素上方,这是一个额外的交互维度。

hover效果在PC端非常重要,它能给用户"这个元素可以交互"的暗示。一个好的PC端应用,鼠标悬浮到可交互元素上时应该有明确的视觉变化——变色、放大、显示工具提示等等。

来看一个综合案例,展示PC端特有的交互模式:hover高亮、click操作、右键菜单、键盘快捷键。

@Entry
@Component
struct PCInteractionDemo {
  @State hoveredItem: number = -1
  @State selectedItem: number = -1
  @State menuVisible: boolean = false
  @State menuX: number = 0
  @State menuY: number = 0
  @State actionLog: string[] = []
  @State shortcutHint: string = ''
  @State focusIndex: number = 0

  private items: string[] = ['项目一', '项目二', '项目三', '项目四']

  build() {
    Column() {
      Text('PC端交互模式')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1F2937')
        .margin({ top: 40, bottom: 6 })

      Text('hover / click / rightClick / keyboard')
        .fontSize(14)
        .fontColor('#9CA3AF')
        .margin({ bottom: 24 })

      // 快捷键提示
      Row() {
        this.KeyHint('Tab', '切换焦点')
        this.KeyHint('Enter', '确认选择')
        this.KeyHint('Esc', '取消')
      }
      .margin({ bottom: 20 })

      // 项目列表
      Column() {
        ForEach(this.items, (item: string, index: number) => {
          Row() {
            // 序号
            Text(`${index + 1}`)
              .fontSize(12)
              .fontColor(index === this.selectedItem ? '#FFFFFF' : '#9CA3AF')
              .fontWeight(FontWeight.Medium)
              .width(24)
              .height(24)
              .backgroundColor(index === this.selectedItem ? '#3B82F6' : '#F3F4F6')
              .borderRadius(6)
              .textAlign(TextAlign.Center)

            // 名称
            Text(item)
              .fontSize(15)
              .fontColor(index === this.selectedItem ? '#1E40AF' : (index === this.hoveredItem ? '#374151' : '#6B7280'))
              .fontWeight(index === this.hoveredItem ? FontWeight.Medium : FontWeight.Normal)
              .margin({ left: 12 })
              .layoutWeight(1)

            // 状态标记
            if (index === this.selectedItem) {
              Text('已选')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .backgroundColor('#3B82F6')
                .borderRadius(6)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            } else if (index === this.hoveredItem) {
              Text('点击选择')
                .fontSize(11)
                .fontColor('#3B82F6')
            }

            // 焦点指示器
            if (index === this.focusIndex) {
              Row()
                .width(3)
                .height(24)
                .backgroundColor('#3B82F6')
                .borderRadius(2)
                .margin({ left: 8 })
            }
          }
          .width('100%')
          .height(52)
          .padding({ left: 16, right: 16 })
          .backgroundColor(
            index === this.selectedItem ? '#EFF6FF' :
            (index === this.hoveredItem ? '#F9FAFB' : '#FFFFFF')
          )
          .borderRadius(12)
          .border({
            width: index === this.focusIndex ? 2 : 0,
            color: index === this.focusIndex ? '#BFDBFE' : '#00000000'
          })
          .onHover((isHover: boolean) => {
            this.hoveredItem = isHover ? index : -1
          })
          .onClick(() => {
            this.selectedItem = index
            this.focusIndex = index
            this.addLog(`点击选择了「${item}`)
          })
          .gesture(
            LongPressGesture({ duration: 600 })
              .onAction(() => {
                this.menuX = 160
                this.menuY = 52 * (index + 1) + 20
                this.menuVisible = true
                this.selectedItem = index
                this.addLog(`长按「${item}」弹出菜单`)
              })
          )
          .margin({ bottom: 8 })
        })
      }
      .width(340)

      // 模拟右键菜单
      if (this.menuVisible) {
        Column() {
          Text('快捷操作')
            .fontSize(11)
            .fontColor('#9CA3AF')
            .width('100%')
            .padding({ left: 16, top: 12, bottom: 8 })

          ForEach(['编辑', '复制', '收藏', '删除'], (action: string, i: number) => {
            Row() {
              Text(action)
                .fontSize(14)
                .fontColor(i === 3 ? '#EF4444' : '#374151')
                .layoutWeight(1)
              Text(`Ctrl+${['E', 'C', 'S', 'D'][i]}`)
                .fontSize(11)
                .fontColor('#D1D5DB')
            }
            .width('100%')
            .height(40)
            .padding({ left: 16, right: 16 })
            .backgroundColor('#FFFFFF')
            .onHover((isHover: boolean) => {
              // hover effect handled by background
            })
            .onClick(() => {
              this.addLog(`执行「${action}」操作`)
              this.menuVisible = false
            })
          })

          Row()
            .width('100%')
            .height(0.5)
            .backgroundColor('#F3F4F6')
            .margin({ top: 4, bottom: 4 })

          Row() {
            Text('取消')
              .fontSize(14)
              .fontColor('#6B7280')
          }
          .width('100%')
          .height(40)
          .padding({ left: 16, right: 16 })
          .onClick(() => {
            this.menuVisible = false
          })
        }
        .width(200)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .shadow({ radius: 20, color: '#1A000000', offsetY: 8 })
        .border({ width: 1, color: '#E5E7EB' })
        .margin({ top: 12 })
      }

      // 操作日志
      Column() {
        Text('操作日志')
          .fontSize(12)
          .fontColor('#9CA3AF')
          .margin({ bottom: 8 })

        if (this.actionLog.length === 0) {
          Text('暂无操作记录')
            .fontSize(13)
            .fontColor('#E5E7EB')
        } else {
          ForEach(this.actionLog, (log: string, index: number) => {
            Text(log)
              .fontSize(12)
              .fontColor(index === 0 ? '#374151' : '#9CA3AF')
              .fontFamily('monospace')
              .width('100%')
              .margin({ bottom: 4 })
          })

          Text('清空')
            .fontSize(11)
            .fontColor('#3B82F6')
            .margin({ top: 8 })
            .onClick(() => {
              this.actionLog = []
            })
        }
      }
      .width(340)
      .padding(16)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .shadow({ radius: 6, color: '#0D000000', offsetY: 2 })
      .margin({ top: 20 })
      .alignItems(HorizontalAlign.Start)

      // 键盘事件监听区域
      Text('按 Tab 切换焦点,Enter 选择,Esc 关闭菜单')
        .fontSize(11)
        .fontColor('#D1D5DB')
        .margin({ top: 12 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FAFBFC')
    .alignItems(HorizontalAlign.Center)
    .onKeyEvent((event: KeyEvent) => {
      if (event.type === KeyType.Down) {
        if (event.keyCode === 2019) { // Tab
          this.focusIndex = (this.focusIndex + 1) % this.items.length
          this.addLog(`Tab切换到项目${this.focusIndex + 1}`)
        } else if (event.keyCode === 2011) { // Enter
          this.selectedItem = this.focusIndex
          this.addLog(`Enter选择了「${this.items[this.focusIndex]}`)
        } else if (event.keyCode === 2014) { // Escape
          this.menuVisible = false
          this.addLog('Esc关闭菜单')
        }
      }
    })
  }

  addLog(message: string): void {
    const now = new Date()
    const time = `${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
    this.actionLog = [`[${time}] ${message}`, ...this.actionLog].slice(0, 6)
  }

  @Builder
  KeyHint(key: string, desc: string) {
    Row() {
      Text(key)
        .fontSize(11)
        .fontColor('#374151')
        .fontWeight(FontWeight.Medium)
        .backgroundColor('#F3F4F6')
        .borderRadius(4)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .border({ width: 1, color: '#E5E7EB' })
      Text(desc)
        .fontSize(11)
        .fontColor('#9CA3AF')
        .margin({ left: 4 })
    }
    .margin({ left: 8, right: 8 })
  }
}

运行效果如图:
在这里插入图片描述

onHover——鼠标悬浮的魔法

onHover()是PC端独有的API。它接收一个boolean参数,true表示鼠标进入元素区域,false表示离开。用法非常简单:

.onHover((isHover: boolean) => {
  this.hoveredItem = isHover ? index : -1
})

在案例里,鼠标悬浮到列表项上时,背景色变灰、文字加粗、右侧出现"点击选择"提示。这些视觉反馈告诉用户"你鼠标在这儿呢,点一下就有反应"。

手机上完全没有这个概念。手机上用户需要"点一下"才知道元素能不能交互,但PC端通过hover就能预先告知。所以PC端的UI可以做得更"安静"——默认状态很简洁,hover之后才展示更多细节。

右键菜单的模拟

PC端用户非常习惯右键弹出上下文菜单。HarmonyOS目前没有直接的onRightClick API(至少在我用的版本里),但可以通过长按手势来模拟。在案例里,长按列表项600ms后弹出一个操作菜单,里面有编辑、复制、收藏、删除等选项。

当然,更好的做法是同时支持真正的右键事件。如果HarmonyOS后续版本支持了右键事件API,直接替换就行。现在用长按作为替代方案,至少让触控用户可以正常使用。

菜单里每个选项右边都标注了键盘快捷键(Ctrl+E/C/S/D),这是PC端的标准做法。用户看一眼菜单就知道快捷键是什么,下次就可以直接用键盘操作,不用鼠标了。

键盘快捷键——PC端的效率倍增器

案例底部的Column挂了.onKeyEvent()来监听键盘事件。Tab切换焦点、Enter确认选择、Esc关闭菜单,这三个快捷键覆盖了列表操作的基本流程。

键盘快捷键在PC端的重要性怎么强调都不过分。重度PC用户(程序员、设计师、文字工作者)对键盘操作的偏好远超你的想象。一个纯鼠标操作的应用,在这些用户眼里就是"不够高效"。

设计快捷键的几个原则:

  • 符合用户习惯:Ctrl+C复制、Ctrl+V粘贴这些是系统级的,不要自己发明
  • 渐进式学习:先让用户用鼠标上手,然后在菜单和提示里展示快捷键
  • 不要冲突:不要覆盖系统的常用快捷键(Alt+F4关闭窗口、Ctrl+S保存等)

PC端手势设计的核心原则

聊了这么多具体技术,总结一下PC端手势交互设计的几个核心原则。

原则一:鼠标优先,触控补充。 PC端的主要交互应该通过鼠标点击完成,手势作为增强手段。不能把一个核心功能只绑定在双指捏合上,因为大部分PC用户可能没有触控板。

原则二:hover是免费的交互维度。 既然PC端有鼠标,就一定要用好hover。悬浮预览、悬浮高亮、悬浮工具提示,这些都能大幅提升用户体验,而且开发成本很低。

原则三:键盘是效率工具。 所有重要操作都应该有键盘快捷键。Tab导航、Enter确认、Esc取消、Ctrl+字母执行命令——这套模式PC用户烂熟于心,学习成本为零。

原则四:右键菜单的替代方案。 手机上长按弹出菜单对应的是PC端的右键菜单。在HarmonyOS PC端,两者都应该提供,功能保持一致。

原则五:元素可以做得更小。 PC端鼠标精度高,可交互元素不需要像手机上那么大。手机上最小48x48dp,PC端24x24就够用了。这样可以提高信息密度,让界面更紧凑。

原则六:反馈要即时。 PC端用户对操作延迟的容忍度比手机低很多。鼠标点击后超过50ms没反应,用户就会觉得"卡了"。所以onClick的回调里尽量少做同步操作。

移动端和PC端的交互模式对比

交互模式 移动端 PC端
选择 触摸点击 鼠标点击
查看操作 长按弹菜单 右键弹菜单
缩放 双指捏合 Ctrl+滚轮
滚动 单指滑动 滚轮/拖动滚动条
悬浮预览 不支持 onHover
快捷键 Ctrl+字母
元素最小尺寸 48x48dp 24x24dp
操作延迟容忍 ~100ms ~50ms

这个表可以贴在开发台上,做PC端设计的时候随时对照。

写在最后

这是这个手势系列的最后一篇了。从最基础的onClick到PanGesture、PinchGesture、RotationGesture,再到今天PC端的交互差异,基本上把HarmonyOS手势交互的核心内容都覆盖了。

回过头来看,手势交互的核心就两件事:选对API、做好细节。API就那么几个,用法也不复杂。但视觉反馈、边界处理、状态恢复这些细节,才是区分"能用"和"好用"的关键。

做PC端的HarmonyOS应用,手势只是输入方式之一。hover、键盘快捷键、右键菜单这些PC端独有的交互模式,也要一起考虑进去。多种输入方式协同配合,才能给PC用户最好的体验。

希望这个系列对你有帮助。如果有什么问题或者建议,欢迎在评论区交流。

Logo

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

更多推荐