触摸反馈技术实战:提升移动端交互体验的关键细节

宇文熙恩 移动 阅读 1,861
赞 21 收藏
二维码
手机扫码查看
反馈

为什么要做触摸反馈?

上个月搞一个移动端的组件库,里面有个卡片列表,用户点一下会跳转详情。产品说“点下去要有反馈,不然用户不知道点没点中”。我一开始觉得加个 :active 伪类不就完了?结果真机一测,iOS Safari 上延迟明显,安卓部分机型干脆没反应。这才意识到,移动端的“点击反馈”不能只靠 CSS,得用 JS 主动控制。

触摸反馈技术实战:提升移动端交互体验的关键细节

后来决定用 touchstart + touchend 来手动加/删 class,模拟“按下去”的视觉效果。思路简单,但实际踩了一堆坑。

最初的实现:简单粗暴版

先写个基础版本:

const cards = document.querySelectorAll('.card');
cards.forEach(card => {
  card.addEventListener('touchstart', () => {
    card.classList.add('pressed');
  });
  card.addEventListener('touchend', () => {
    card.classList.remove('pressed');
  });
});
.card {
  transition: background-color 0.1s;
}
.card.pressed {
  background-color: #f0f0f0;
}

本地跑起来没问题,点哪哪变灰。但真机测试发现两个问题:

  • 手指滑动(比如想上下滚动)时,也会触发 touchstart,卡片变灰了,但其实用户根本不是要点它
  • 如果用户长按超过 300ms,系统会弹出菜单(比如复制、分享),这时候 touchend 不会触发,卡片就一直灰着

最大的坑:滚动和误触

第一个问题最头疼。用户明明在滑动页面,结果划过卡片时它变灰了,体验特别差。开始我以为是事件冒泡问题,试了 stopPropagation,没用。后来才明白,关键在于区分“点击”和“滑动”。

查了下资料,主流做法是记录 touchstart 的坐标,在 touchmove 中判断偏移量。如果偏移超过某个阈值(比如 10px),就认为是滑动,取消反馈。

于是改代码:

const TOUCH_THRESHOLD = 10; // 像素

cards.forEach(card => {
  let startX = 0;
  let startY = 0;
  let isMoved = false;

  card.addEventListener('touchstart', (e) => {
    const touch = e.touches[0];
    startX = touch.clientX;
    startY = touch.clientY;
    isMoved = false;
    card.classList.add('pressed');
  });

  card.addEventListener('touchmove', (e) => {
    if (isMoved) return;
    const touch = e.touches[0];
    const dx = Math.abs(touch.clientX - startX);
    const dy = Math.abs(touch.clientY - startY);
    if (dx > TOUCH_THRESHOLD || dy > TOUCH_THRESHOLD) {
      isMoved = true;
      card.classList.remove('pressed');
    }
  });

  card.addEventListener('touchend', () => {
    if (!isMoved) {
      // 这里可以触发真正的点击逻辑
      console.log('clicked');
    }
    card.classList.remove('pressed');
  });

  // 防止长按后状态残留
  card.addEventListener('touchcancel', () => {
    card.classList.remove('pressed');
  });
});

这样改完,滑动时不会再误触发反馈了。但又冒出新问题:在 iOS 上,如果快速点击,有时候 touchend 没触发,或者触发了但 class 没及时移除,导致视觉残留。折腾了半天,发现是浏览器合成层的问题——当元素频繁添加/删除 class 时,如果没触发硬件加速,动画可能卡住。

性能优化:强制开启硬件加速

.pressed 加了个 transform: translateZ(0),强制开启 GPU 渲染:

.card.pressed {
  background-color: #f0f0f0;
  transform: translateZ(0);
}

这招亲测有效,残留问题基本消失。不过要注意,别滥用 translateZ(0),否则会增加内存占用,尤其在低端安卓机上。

兼容性问题:还得兜底 click 事件

虽然我们主要处理 touch 事件,但有些设备(比如 iPad 配合鼠标)可能只触发 click。保险起见,还是得监听 click 作为 fallback:

// 在 touchend 逻辑里加个标记
let hasTouch = false;

cards.forEach(card => {
  card.addEventListener('touchstart', () => {
    hasTouch = true;
    // ...之前的逻辑
  });

  card.addEventListener('click', (e) => {
    if (!hasTouch) {
      // 没有 touch 事件,说明是鼠标点击
      card.classList.add('pressed');
      setTimeout(() => {
        card.classList.remove('pressed');
      }, 150);
    }
  });
});

这里用 setTimeout 是为了模拟按下的持续时间,避免闪一下就没了。150ms 是经验值,太短没感觉,太长又拖沓。

最终效果和遗留问题

上线后整体反馈不错,用户说“点起来有感觉了”。性能也还行,60fps 稳得很。但有两个小问题没彻底解决:

  • 在极少数安卓机上(比如三星老款),touchcancel 不触发,长按后状态残留。目前靠加个全局的 setTimeout 自动清除,有点糙但能用
  • 如果用户同时点多个卡片(比如双指),当前逻辑只处理第一个 touch,其他会被忽略。不过产品说“移动端单指操作为主”,就没深究

其实还有更优雅的方案,比如用 Pointer Events API,但兼容性不够(iOS 13+ 才支持),项目要兼容 iOS 12,只能放弃。

回顾与反思

这次做触摸反馈,最大的教训是:别以为 CSS 伪类能搞定一切。移动端交互细节多,必须结合 JS 精细控制。另外,测试一定要真机多测,模拟器根本看不出滑动误触这种问题。

代码虽然啰嗦了点,但胜在稳定。如果你也在做类似需求,建议直接用上面的方案,至少能避开我踩过的大部分坑。对了,记得把 TOUCH_THRESHOLD 调成 8-12 之间,太小容易误判,太大又不灵敏。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如怎么更好地处理多点触控,或者有没有轻量级的库推荐。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论