跳到正文

不要用屏幕宽度判断触摸交互:一次照片网格拖拽排序修复

有一类移动端问题很容易被桌面调试骗过去:Chrome DevTools 缩成手机宽度时一切正常,真机上却发现普通上下滑被拖拽抢走,或者本该长按出现的菜单变成了一个很难点的小 Popover。

这次问题出在一个照片网格里。外层照片项在桌面端应该 hover 出右上角三点菜单,点击后展示悬浮菜单;在手机和平板上应该长按图片,底部拉出 ActionSheet。上传弹窗里还支持拖拽排序:桌面端短按缩略图即可拖动,触摸设备上却不能一按就抢走 touchmove,否则用户想上下滑弹窗时会被当成排序。

最初看起来可以用屏幕宽度分流:宽屏当 PC,小屏当 H5。但这条线很快就不稳了。平板可能是宽屏触摸设备;桌面浏览器可以缩成手机宽;iPad 外接鼠标时又更像桌面交互。屏幕宽度决定的是布局密度,不决定用户此刻用什么输入方式。

先把两个问题拆开

响应式页面里,至少有两类判断:

  • 布局判断:一行放几列、侧边栏还是底栏、面板宽度是多少。
  • 交互判断:用户能不能 hover,指针是否精细,当前输入更像鼠标还是手指。

isMobile、isPad、isPC 更适合回答第一类问题。比如照片网格在移动端 3 列、桌面端 5 列,这确实和可用宽度有关。

菜单打开方式和拖拽触发方式属于第二类问题。这里真正要问的是:

  • 用户是否有稳定 hover 能力?
  • 指针是否足够精细,适合点一个小 Popover 触发器?
  • 手指滑动时,拖拽库会不会抢走页面滚动?

CSS Media Queries 里已经有这类能力查询:

@media (hover: hover) and (pointer: fine) {
  /* 鼠标 / 触控板这类可 hover 且指针精细的场景 */
}

@media (hover: none), (pointer: coarse) {
  /* 触摸 / 粗指针场景 */
}

JS 里也应该用同一个口径:

const canUsePointerPopover = useMediaQuery('(hover: hover) and (pointer: fine)')

这组查询适合选择默认交互,但反映的是主要指针能力,不能准确代表混合设备上的每一次操作。iPad 接鼠标、触屏电脑用手指时,还要结合实际事件的 pointerType,并响应媒体查询变化。菜单也必须有键盘可聚焦的入口,不能只靠 hover 或长按才能发现。

宽度选择布局,输入能力选择交互入口

菜单只分流打开方式,不分裂业务动作

照片项菜单有两个动作:更换图片、删除图片。桌面端是 Popover,触摸端是 ActionSheet,但业务动作应该是同一组。

推荐做法是只分流「怎么打开菜单」:

const actions = [
  { key: 'replace', label: '更换图片' },
  { key: 'delete', label: '删除图片' },
]

function openMenu(source?: Event | HTMLElement | null) {
  const isTouchEvent = !!source && 'pointerType' in source && source.pointerType === 'touch'
  if (!isTouchEvent && canUsePointerPopover.value) {
    openPopover(source)
    return
  }

  openActionSheet()
}

function selectAction(action: { key: string }) {
  const selectedAction = actions.find(item => item.key === action.key)
  if (!selectedAction) return

  runBusinessAction(selectedAction)
}

这里有一个小细节:如果 ActionSheet 是通用组件,它返回的可能只是基础 action 类型;业务菜单里可能还有更窄的字段,比如 tone、图标尺寸或业务枚举。更稳的做法是拿返回的 key 回到原始 actions 里找一次,再把原始 action 交给业务层。这样类型和数据来源都不会被通用层磨平。

反过来,最容易出问题的是维护两套菜单:

const popoverActions = [...]
const actionSheetActions = [...]

这会让后续新增一个「设为封面」时只改了一端。交互外壳分开可以,业务动作不要分裂。

CSS 也要按输入能力走

JS 里按 media query 分流还不够,CSS 也要同口径。

桌面端可以默认隐藏三点按钮,hover 图片项后再显示:

.photo-item__more {
  opacity: 1;
}

@media (hover: hover) and (pointer: fine) {
  .photo-item__more {
    opacity: 0;
  }

  .photo-item:hover .photo-item__more,
  .photo-item:focus-within .photo-item__more {
    opacity: 1;
  }
}

主要靠触摸操作时不能依赖 hover 展示入口。三点按钮应可聚焦、可用键盘或点击打开,长按作为补充;精细指针下也要用 :focus-within 显示按钮,避免键盘焦点落在不可见控件上。

这里也不要加一条 width > 1280px。宽度不是输入能力。一个 1024px 的 iPad 是触摸设备,一个 390px 的桌面模拟器仍然有鼠标。CSS 和 JS 判断条件不一致时,最常见的症状就是:JS 走 ActionSheet,CSS 却隐藏了触发器;或者 CSS 显示 hover 态,JS 却按移动端逻辑处理。

拖拽排序不要抢普通滚动

上传弹窗里的缩略图要支持排序。桌面鼠标短按拖动是自然的,但触摸设备上,如果手指刚碰到缩略图就进入拖拽,用户就没法正常上下滑弹窗。

不要用 touch-action: none 或全局 preventDefault 去压住问题。它们确实能让拖拽更「强」,但代价是把滚动也一起压掉。对弹窗、图片列表、WebView 来说,这通常是更大的问题。

更好的策略是让触摸设备长按后才进入排序。以 SortableJS 为例:

Sortable.create(gridElement, {
  animation: 150,
  draggable: '.photo-grid__slot--has-photo',

  // 鼠标 / 触控板仍然短按拖动;触摸设备需要长按进入排序。
  delay: 450,
  delayOnTouchOnly: true,
  touchStartThreshold: 10,
})

这三个配置分别解决不同问题:

  • delay:触摸开始后等待一段时间再进入拖拽。
  • delayOnTouchOnly:只对触摸生效,鼠标拖拽不延迟。
  • touchStartThreshold:手指在等待期间移动超过阈值时,取消这次拖拽意图,让普通滚动先成立。

用户快速滑动弹窗时,排序不会抢走 touchmove;用户按住缩略图一小段时间后,再移动才是排序。

等待长按时,先允许普通滚动取消排序

鼠标端还要给出可拖动提示

触摸端靠长按进入排序,不需要额外光标提示。桌面端则应该明确告诉用户缩略图能拖:

@media (hover: hover) and (pointer: fine) {
  .photo-grid__slot--has-photo {
    cursor: grab;
  }

  .photo-grid__slot--has-photo:active {
    cursor: grabbing;
  }
}

这个提示也应该按输入能力加,不要全局给触摸设备写 cursor: grab。触摸设备没有鼠标光标,写了也没有帮助,反而会让维护者误以为这里是统一鼠标体验。

测试要守住根因

这类问题靠截图不太稳。截图能看到菜单长什么样,却很难证明交互分流是不是正确。

更适合补几类低成本测试:

  • mock useMediaQuery('(hover: hover) and (pointer: fine)'),覆盖 fine pointer 走 Popover、coarse pointer 走 ActionSheet。
  • source 级断言不再出现 width > 1280px 这类交互分流条件。
  • 断言 SortableJS 配置包含 delay、delayOnTouchOnly 和 touchStartThreshold。
  • 断言上传弹窗没有引入 touch-action: none。

手工验收也要按能力,而不是只按宽度:

  • 桌面鼠标:hover 出三点,点击出 Popover,短按缩略图能拖动。
  • 键盘:Tab 到三点按钮时入口可见,Enter / Space 能打开菜单;触屏电脑用手指时按实际 pointerType 处理。
  • 手机 / 平板手指:外层照片长按打开 ActionSheet,上传弹窗普通上下滑正常,长按缩略图后才能拖动。
  • 桌面浏览器缩到手机宽度:只验证布局,不把它当成触摸交互证明。

总结

照片网格的列数随宽度调整,菜单入口与排序触发则按输入能力选择。Popover 和 ActionSheet 共用同一组业务动作,CSS 与 JS 使用一致的能力判断;主要指针查询只提供默认依据,混合设备还要结合实际 pointerType,键盘入口也要始终可发现。触摸排序通过延迟和移动阈值区分长按与滑动,让普通滚动优先成立,鼠标仍可短按拖动。缩窄桌面浏览器只能验证布局,触摸与键盘是否可用,仍要按各自的操作方式验收。