微交互设计实战提升用户体验的细节优化
又踩坑了,touchmove滚动失效
今天在搞一个移动端的滑动卡片组件,加了个简单的微交互:手指滑动时,卡片跟着偏移,松手后判断滑动距离决定是回弹还是滑走。逻辑不复杂对吧?结果一上手就翻车了。
问题出在安卓机上,iOS还好,但一到某些安卓浏览器(特别是微信内置的那种),整个页面的滚动直接卡死了,手指划下去没反应,上下滑都废了。我第一反应是:是不是我把 touchmove 阻止得太狠了?
查了一下代码,果然,我在 touchmove 里直接来了个 e.preventDefault(),想着防止默认行为干扰滑动效果。但这一下子就把整个页面的滚动也给干掉了。这里我踩了个坑:不是所有 touchmove 都该阻止,默认滚动得让人家滚。
三种方案对比,我选了最简单的
后来试了下发现,其实社区里早就有成熟的解法。常见的有三种:
- 方案一:用 CSS
touch-action: pan-y,让垂直方向保留原生滚动 - 方案二:在 JS 中动态判断滑动方向,只在水平滑动时 preventDefault
- 方案三:完全不用 touch 事件,改用 Pointer Events
我一开始想上方案三,觉得高大上,结果发现兼容性有点悬,尤其低端安卓机支持不好,还得加 polyfill,太重了。方案二听着靠谱,但写起来容易出 bug,比如刚开始滑动的时候方向判断不准,抖动一下就误判成水平滑了,然后页面就不能滚了,用户体验更糟。
最后我还是回到方案一,简单粗暴但有效。核心就是一句话:
.swipe-card {
touch-action: pan-y;
}
这样一来,系统就知道这个元素你主要用来做水平滑动(pan-x),垂直方向请保留给页面滚动。完美避开手动阻止事件带来的副作用。
核心代码就这几行
当然光靠 CSS 不够,JS 还是要配合一下,不然滑动反馈还是不跟手。我的做法是监听 touchstart、touchmove 和 touchend,但在 move 阶段不再无脑阻止事件,而是让浏览器自己处理。
完整实现如下:
const card = document.querySelector('.swipe-card');
let isSwiping = false;
let startX, currentX;
card.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
isSwiping = true;
});
card.addEventListener('touchmove', (e) => {
if (!isSwiping) return;
currentX = e.touches[0].clientX;
const diff = currentX - startX;
// 只在水平位移明显时才应用 transform
// 避免轻微抖动影响体验
if (Math.abs(diff) > 5) {
card.style.transform = translateX(${diff}px);
card.style.transition = 'none'; // 关闭过渡,保持跟随
}
});
card.addEventListener('touchend', () => {
if (!isSwiping) return;
const diff = currentX - startX;
const threshold = 100; // 滑动阈值
if (Math.abs(diff) > threshold) {
card.style.transform = translateX(${diff > 0 ? 300 : -300}px);
card.style.transition = 'transform 0.3s ease';
// 动画结束后触发删除或跳转
setTimeout(() => {
card.remove();
}, 300);
} else {
// 回弹
card.style.transform = '';
card.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
}
isSwiping = false;
});
踩坑提醒:这三点一定注意
折腾了半天发现,有几个细节特别容易出问题:
- transition 别忘了关:在
touchmove期间一定要把 transition 设为 none,否则每次位移都会有动画延迟,看起来卡顿不跟手。这点很多人忽略,包括我第一次写的时候。 - 阈值判断要宽松点:一开始我把滑动判定设得太敏感,稍微一碰就算 swipe,结果用户想看文字时不小心划了一下卡片飞了,很恼火。后来改成必须超过 100px 才触发移除,体验好多了。
- 别在 touchmove 里做太多计算:之前我在 move 里加了惯性速度估算,结果低端机直接掉帧。现在只做位移更新,其他放到 end 阶段再算,性能稳多了。
fetch 接口示例顺带写个
这个卡片滑走之后会标记为“已读”,所以需要调接口。虽然和微交互关系不大,但顺便贴一下请求部分怎么写的:
async function markAsRead(cardId) {
try {
const response = await fetch('https://jztheme.com/api/cards/read', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: cardId })
});
if (!response.ok) throw new Error('Network error');
} catch (err) {
console.warn('标记失败,稍后重试', err);
// 失败也不阻塞 UI,可考虑本地缓存状态
}
}
上面这个函数会在卡片滑出后调用,失败了也不会弹 Toast,太打扰用户。毕竟只是个微操作,别因为网络抖动破坏体验。
改完后还有一两个小问题,但无大碍
目前唯一的瑕疵是:在快速连续滑动几张卡片时,偶尔会出现某张卡没触发 remove 的情况。排查发现是因为 transition 没结束就执行了 remove,导致 DOM 被删了但动画卡住。临时方案是在 setTimeout 里加点冗余时间,比如 400ms,比动画时间多 100ms 容错。
更好的做法应该是监听 transitionend 事件,但要考虑取消滑动时的清理,逻辑更复杂。现阶段这个小概率问题可以接受,先上线观察。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流
说实话,这种交互看着简单,真要做丝滑还挺考验细节把控的。尤其是不同机型、不同浏览器之间的差异,有时候你以为 ok 的写法,在别人手机上就是不灵。
这次最大的收获是:别急着用 JS 把控制权全抢过来,先看看 CSS 能不能帮你解决问题。touch-action 这个属性以前一直忽略,现在发现它才是移动端手势处理的第一道防线。
另外,微交互的核心不是炫技,而是让用户感觉“这玩意儿本该如此”。做得好没人注意,做砸了立马被骂,真是吃力不讨好的活儿……但谁让我们是前端呢。

暂无评论