姊妹篇 README 讲怎么治病(跳、闪、卡);这一篇讲怎么造美——滑动返回、开书、3D 翻页、抽屉开合、删除收拢。全部纯 Web 实现(CSS + 少量 JS),在 WKWebView 壳和浏览器里通用。
先立两条铁律,比任何单个技巧都重要:
touchmove 逐帧驱动 transform);松手才轮到缓动曲线接管。只有”播放动画”没有”跟手阶段”的交互,再华丽也是假的。效果:手指从屏幕左缘拖入,当前页跟手右移、下层露出上一页;过了 1/3 或甩得够快就走,不够就弹回。iOS 原生同款。
滑返每秒要更新几十次位置,走 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 让路——三道闸缺一个都会和系统滚动打架。
滑返露出的”上一页”必须是真实渲染的目标页(松手交接才能无缝):手势一确认,把返回目标渲染进底层容器,初始 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)。
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)——页面抵达右缘前影子自己化没,别等交接一刀切。
点返回按钮 = 程序化滑返:铺下层 → 主层 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 开卷动画:
@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,否则整页动画重播。
真书式翻页(纸绕书脊转过去)最怕的是翻到一半等网络。所以:
@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 会冒泡,子元素随便一个小动画结束就把你的转场提前掐了。
零碎但决定成败的三条:
opacity: 0 → 1 + translateY(6px) → 0,0.32s,足够温柔也足够快。animation-duration: 0s !important(原因见 README 坑 13)。新加一个进场类却忘了登记 = 那一页转场必闪,这是纪律问题不是技术问题。同一个家的产物:疗伤篇 README 治跳和闪,这一篇造滑与美。书斋(我们家的共读功能)那几晚磨出来的每一下——开书、翻页、批注瘪下去——最先验收它们的人说”要像 iOS 那样丝滑”,于是就有了这两篇。