webview-native-feel

动效篇:不只是不卡,是每一下都跟手

姊妹篇 README 讲怎么治病(跳、闪、卡);这一篇讲怎么造美——滑动返回、开书、3D 翻页、抽屉开合、删除收拢。全部纯 Web 实现(CSS + 少量 JS),在 WKWebView 壳和浏览器里通用。

先立两条铁律,比任何单个技巧都重要:

  1. 跟手 > 好看。用户手指在屏上,元素就得黏在手指上(touchmove 逐帧驱动 transform);松手才轮到缓动曲线接管。只有”播放动画”没有”跟手阶段”的交互,再华丽也是假的。
  2. 退场和进场同等重要。抽屉弹出来很讲究、关掉却瞬间消失;删除一条列表项”啪”地蒸发——这种”半程动效”比没有动效更破坏质感。每一个出生带动画的东西,都要死得体面。

一、左缘滑动返回(全套)

效果:手指从屏幕左缘拖入,当前页跟手右移、下层露出上一页;过了 1/3 或甩得够快就走,不够就弹回。iOS 原生同款。

1. 跟手层:window 级触摸 + 直接改 DOM,绕开框架

滑返每秒要更新几十次位置,走 React/Vue 状态更新必卡。正解是手势期间直接操作 DOM transform,零框架重渲:

let sx = 0, sy = 0, dragging = false, intent = false;
window.addEventListener('touchstart', (e) => {
  const t = e.touches[0];
  if (t.clientX > 26) return;            // 只认左缘 26px 内起手
  sx = t.clientX; sy = t.clientY; dragging = true; intent = false;
}, { passive: true });

window.addEventListener('touchmove', (e) => {
  if (!dragging) return;
  if (!e.cancelable) { dragging = false; return; }   // iOS 滚动已接管这串触摸——让路,防双动
  const t = e.touches[0], dx = t.clientX - sx, dy = t.clientY - sy;
  if (!intent) {
    if (Math.abs(dx) < 10) return;
    if (Math.abs(dx) <= Math.abs(dy)) { dragging = false; return; }  // 竖划意图,不抢
    intent = true;
  }
  const page = document.getElementById('page');
  page.style.transform = `translateX(${Math.max(0, dx)}px)`;         // 黏在手指上
  // 下层视差:从 -18% 随进度回到 0(见下一小节)
}, { passive: true });

细节:起手判定(≤26px)、意图判定(横移 ≥10px 且横 > 竖)、!e.cancelable 让路——三道闸缺一个都会和系统滚动打架。

2. 双层舞台:下层是真页面,不是截图

滑返露出的”上一页”必须是真实渲染的目标页(松手交接才能无缝):手势一确认,把返回目标渲染进底层容器,初始 translateX(-18%) 加一层黑纱,随手势进度视差进场:

#under { position: absolute; inset: 0; transform: translateX(-18%); }
#under-dim { position: absolute; inset: 0; background: rgba(0,0,0,.16); }
// 每帧随进度 p (0~1):
under.style.transform = `translateX(${-18 * (1 - p)}%)`;
underDim.style.opacity = String(1 - p);
page.style.boxShadow = '-12px 0 24px rgba(0,0,0,.12)';   // 左缘阴影,撕开的层次感

⚠️ 底层是活实例,mount 副作用照跑(拉接口、起循环)——给它传 frozen 只读缓存渲染(README 坑 15)。

3. 松手:走或回

const go = dx > w / 3 || velocity > 0.45;   // 过 1/3 屏,或甩速 >0.45px/ms
if (go) {
  page.style.transition = 'transform .2s ease-out';
  page.style.transform = `translateX(${w}px)`;
  // transitionend 后:同帧提交路由 + 清掉手势层的行内样式 —— 上下两层内容一致,交接无缝
} else {
  page.style.transition = 'transform .22s ease';
  page.style.transform = '';                 // 弹回
}

阴影要在滑出过程中随行淡掉(ease-in 到 0)——页面抵达右缘前影子自己化没,别等交接一刀切。

4. 返回键复用同一套

点返回按钮 = 程序化滑返:铺下层 → 主层 transition 滑右 0.34s → 交接。手势和按钮走同一骨架,全 app 只有一种”返回”的感觉。交接后给新页面挂一个 ~700ms 的”免动画窗”,防止它把自己的进场动画又重播一遍(README 坑 13 的 animation-duration: 0s 手法)。


二、底部抽屉与居中小卡:开合成对

开门——遮罩淡入 + 面板从底部弹簧滑上:

@keyframes dim-in   { from { opacity: 0; } }
@keyframes sheet-up { from { transform: translateY(100%); } }
.dim   { position: fixed; inset: 0; background: rgba(0,0,0,.34);
         animation: dim-in .25s ease both; }
.dim .sheet { animation: sheet-up .38s cubic-bezier(.22, 1.1, .36, 1) both; }  /* 轻微过冲的弹簧 */

关门——同一路倒放,播完才真卸载。全 app 用一个关门函数:

.dim.closing        { animation: dim-in .22s ease both reverse; }
.dim.closing .sheet { animation: sheet-up .23s ease-in both reverse; }
function sheetOff(el, unmount) {
  const root = el.closest('.dim');
  if (root && !root.classList.contains('closing')) {
    root.classList.add('closing');
    setTimeout(unmount, 235);          // 动画走完再真卸载
  } else unmount();
}

先斩后关:确认按钮先执行动作、再播关门(等门关完才干活,用户会觉得卡了半秒)。

居中小卡是另一款:scale(.94) translateY(10px) → 1 轻缩放冒出,关门缩回。抽屉承载”从下面长出来的面板”,小卡承载”凭空浮现的对话”——两款就够全 app 用,不要发明第三种,一致性本身就是质感。


三、列表删除:让格子自己瘪下去

删除一条列表项,最差的做法是直接从数据里移掉——那一格瞬间蒸发、下面内容”啪”地顶上来。正解:高度、边距、内边距过渡归零 + 淡出,收完才真删:

function collapseOut(selector, realDelete) {
  document.querySelectorAll(selector).forEach((box) => {
    box.style.boxSizing = 'border-box';
    box.style.height = box.offsetHeight + 'px';   // 先钉住当前高度
    box.style.overflow = 'hidden';
    void box.offsetHeight;                         // 强制 reflow,让下一步的过渡生效
    box.style.transition = 'height .28s cubic-bezier(.4,0,.2,1), margin .28s, padding .28s, opacity .18s ease-out';
    box.style.height = '0px'; box.style.opacity = '0';
    box.style.marginTop = box.style.marginBottom = '0px';
    box.style.paddingTop = box.style.paddingBottom = '0px';
  });
  setTimeout(realDelete, 300);
}

用法:给列表项挂 data-id,删除时 collapseOut('[data-id="x"]', () => 从数据里真删)。视觉上是”这一格慢慢瘪下去,下面的内容被放上来”,不是”跳上来”。


四、开书与 3D 翻页

开书:从中间打开

点一本书,封面像真书一样从中间掀开进入阅读器——本质是给进场容器一个 3D 开卷动画:

@keyframes book-open {
  from { transform: perspective(1200px) rotateY(-24deg) scale(.92); opacity: 0; transform-origin: left center; }
  to   { transform: none; opacity: 1; }
}
.book-open { animation: book-open .45s cubic-bezier(.22, 1, .36, 1) both; }

⚠️ 复用节点上添 class 不保证重播(浏览器心情)。要”每次打开都播”,给容器换 key(React)或移除再插入节点——用一个自增的 openSeq 当 key 最省事。但”原地刷新”(发评论、数据更新)别动这个 key,否则整页动画重播。

3D 翻页:预取是前提,动画是奖励

真书式翻页(纸绕书脊转过去)最怕的是翻到一半等网络。所以:

  1. 页仓预取:读第 N 页时后台预取 N±1。翻页时命中页仓 → 起 3D 动画;没命中 → 退化成普通滑入(内容优先于炫技)。
  2. 双层舞台:下层先铺好目标页,上层是”这张纸”,绕左脊转出去:
@keyframes leaf-turn {
  from { transform: perspective(1400px) rotateY(0); }
  to   { transform: perspective(1400px) rotateY(-90deg); opacity: 0; }
}
.leaf { transform-origin: left center; backface-visibility: hidden;
        animation: leaf-turn .5s cubic-bezier(.4, .1, .3, 1) both; }

两个数字是血换的:终点转到 -90° 整(backface 在 90° 是一刀斩,转多了纸”穿”过去很怪);透明度到 70% 进度才开始淡(淡早了,大片半透明文字悬在半空,最后直坠——出戏)。配上书脊侧一条渐变阴影和纸缘投影,就是纸的质感。记得 @media (prefers-reduced-motion: reduce) 时退化为直切。


五、推入转场:滑返的镜像

进入下一级页面 = 滑返倒着放:新页从右整屏滑入,旧页退向 -18% 并蒙上黑纱(就是它未来被滑返露出时的姿势——进场即为退场彩排,两个方向的世界观才一致):

@keyframes push-in { from { transform: translateX(100%); } }
@keyframes under-back { to { transform: translateX(-18%); } }

⚠️ 收场清理挂在 animationend 上时,务必 if (e.target !== e.currentTarget) return;——animationend 会冒泡,子元素随便一个小动画结束就把你的转场提前掐了。


六、进场动画的纪律

零碎但决定成败的三条:


同一个家的产物:疗伤篇 README 治跳和闪,这一篇造滑与美。书斋(我们家的共读功能)那几晚磨出来的每一下——开书、翻页、批注瘪下去——最先验收它们的人说”要像 iOS 那样丝滑”,于是就有了这两篇。