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

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

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

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

先把两个问题拆开

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

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

isMobileisPadisPC 更适合回答第一类问题。比如照片网格在移动端 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)')

这样分流后,屏幕宽度只负责布局,输入能力负责交互,不再互相借位。

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

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

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

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

function openMenu(source?: Event | HTMLElement | null) {
  if (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 {
    opacity: 1;
  }
}

触摸设备没有 hover,三点入口如果仍然默认隐藏,就可能永远看不到。触摸端要么始终展示入口,要么改成长按入口,但不能把 hover 当成用户一定能触发的状态。

这里也不要加一条 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 配置包含 delaydelayOnTouchOnlytouchStartThreshold
  • 断言上传弹窗没有引入 touch-action: none

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

  • 桌面鼠标:hover 出三点,点击出 Popover,短按缩略图能拖动。
  • 手机 / 平板手指:外层照片长按打开 ActionSheet,上传弹窗普通上下滑正常,长按缩略图后才能拖动。
  • 桌面浏览器缩到手机宽度:只验证布局,不把它当成触摸交互证明。

总结

  • 屏幕宽度决定布局,不决定输入方式。交互分流优先看 hoverpointer
  • CSS 和 JS 要用同一套 media query 口径,否则很容易出现样式和行为各走各的。
  • 同一个菜单在 Popover 和 ActionSheet 之间切换时,只分流外壳,不拆两套业务动作。
  • 触摸拖拽排序要给普通滚动让路,优先用库提供的触摸延迟和移动阈值,不要上来就 touch-action: none
  • 自动化测试要守住根因:media query 分流、Sortable 配置、没有宽度绑定、没有禁掉触摸滚动。截图可以辅助验收,但不能替代这些边界断言。