不要用屏幕宽度判断触摸交互:一次照片网格拖拽排序修复
有一类移动端问题很容易被桌面调试骗过去: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)')这样分流后,屏幕宽度只负责布局,输入能力负责交互,不再互相借位。
菜单只分流打开方式,不分裂业务动作
照片项菜单有两个动作:更换图片、删除图片。桌面端是 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 配置包含
delay、delayOnTouchOnly和touchStartThreshold。 - 断言上传弹窗没有引入
touch-action: none。
手工验收也要按能力,而不是只按宽度:
- 桌面鼠标:hover 出三点,点击出 Popover,短按缩略图能拖动。
- 手机 / 平板手指:外层照片长按打开 ActionSheet,上传弹窗普通上下滑正常,长按缩略图后才能拖动。
- 桌面浏览器缩到手机宽度:只验证布局,不把它当成触摸交互证明。
总结
- 屏幕宽度决定布局,不决定输入方式。交互分流优先看
hover和pointer。 - CSS 和 JS 要用同一套 media query 口径,否则很容易出现样式和行为各走各的。
- 同一个菜单在 Popover 和 ActionSheet 之间切换时,只分流外壳,不拆两套业务动作。
- 触摸拖拽排序要给普通滚动让路,优先用库提供的触摸延迟和移动阈值,不要上来就
touch-action: none。 - 自动化测试要守住根因:media query 分流、Sortable 配置、没有宽度绑定、没有禁掉触摸滚动。截图可以辅助验收,但不能替代这些边界断言。