从补间到叙事:GSAP 动画实战 · 公开阅读
第16章 Observer插件
统一处理鼠标、触摸与指针输入,构建交互动画。
本章小节
16.1 Observer是什么
网页上实现拖拽功能时,通常需要写两份代码:一份监听 mousedown/mousemove/mouseup(鼠标拖),一份监听 touchstart/touchmove/touchend(手指拖)。移动端还可能出现事件重复触发的问题,两种交互逻辑几乎相同,只是换了事件名。
Observer 就是来解决这个问题的。它将鼠标、触摸、滚轮、触控笔等不同设备的输入统一翻译成标准的手势信号,同时自动识别高级手势:滑动方向、拖拽距离、拖拽速度等,不需要手动计算。
一行代码看懂它能干什么:
Observer.create({
target: window,
type: "wheel,touch,pointer",
onChange: (self) => console.log("变化了"),
onDrag: self => gsap.set(box, { x: "+=" + self.deltaX, y: "+=" + self.deltaY }), // 累加增量;box 为已取得的元素引用
});Observer 的核心理念:不关心你用什么设备交互,只关心交互产生了什么变化量。
16.2 为什么需要Observer
原生事件的碎片化
| 事件类型 | 原生API | 麻烦在哪 |
|---|---|---|
| 鼠标滚轮 | wheel | 不同浏览器 deltaY 差异巨大,有的20有的100多 |
| 触摸 | touchstart/touchmove/touchend | 需要手动计算位移、速度、方向 |
| 鼠标 | mousedown/mousemove/mouseup | 与触摸事件逻辑重复但写法不同 |
| 指针 | pointerdown/pointermove/pointerup | touch和mouse会重复触发(移动端尤其头疼) |
| 滚动 | scroll | 无法区分用户滚轮滚动还是JS触发的滚动 |
Observer 用一个对象统一了这五类事件。再不需要写两份逻辑分别处理鼠标和触摸了。
从"事件监听"到"行为抽象"——直接告诉你结果
原生事件告诉你的信息是低层的:"哪个手指在哪个坐标按下了"。你还需要自己去推理"这是在拖拽吗?拖了多远?往哪个方向拖的?"
Observer 直接告诉你高层结论:"拖拽了15px" 或 "向右滑动了"。
// 原生写法:40行代码,还要写两份(鼠标+触摸)
let startX, startY, isDragging = false;
el.addEventListener("mousedown", e => { startX = e.clientX; startY = e.clientY; isDragging = true; });
el.addEventListener("mousemove", e => { if (!isDragging) return; /* 计算位移... */ });
el.addEventListener("mouseup", () => { isDragging = false; });
// 还要再写一份 touch 版本...
// Observer写法:5行搞定
Observer.create({
target: el, type: "mouse,touch",
onDrag: (self) => { gsap.set(el, { x: '+=' + self.deltaX, y: '+=' + self.deltaY }); }
});16.3 Observer.create() 参数详解
大部分时候需要明确监听什么(type),拖拽时做什么(onDrag),以及在哪种输入方向或释放时执行操作(onLeft、onRight、onRelease)。Observer 没有 onSwipe 或 onSwipeLeft 等回调。
const obs = Observer.create({
// ===== 基础配置 =====
target: window, // 监听目标,默认window
type: "wheel,touch,pointer", // 事件类型,逗号分隔
// 可选值: "wheel" | "touch" | "pointer" | "mouse" | "scroll"
// ===== 行为参数 =====
dragMinimum: 5, // 最小拖拽距离(px),小于此值的拖拽被当作"只是点了一下"
preventDefault: true, // 是否阻止默认行为
lockAxis: false, // 布尔值:true 时锁定首次明显运动的轴
capture: true, // 是否使用事件捕获阶段
tolerance: 10, // 方向判定的容错范围
// ===== 回调函数(核心,按需选用) =====
onChange: (self) => {}, // 任何类型事件触发时
onDown: (self) => {}, // 输入向下运动
onUp: (self) => {}, // 输入向上运动
onPress: (self) => {}, // 鼠标/触摸按下,不代表长按计时
onRelease: (self) => {}, // 鼠标/触摸释放
onClick: (self) => {}, // 点击(没有明显拖动的按下+抬起)
onDrag: (self) => {}, // 拖拽中(手指按住不放然后移动)
onDragStart: (self) => {}, // 拖拽开始
onDragEnd: (self) => {}, // 拖拽结束
onHover: (self) => {}, // 鼠标悬停(鼠标在元素上方悬停但不点击)
onHoverEnd: (self) => {}, // 离开
onToggleY: (self) => {}, // Y轴方向切换
onLeft: (self) => {}, // 输入向左运动
onRight: (self) => {}, // 输入向右运动
onStop: (self) => {}, // 交互停止
// ===== 滚轮专用 =====
wheelSpeed: 1, // 滚轮灵敏度倍数(设为0.5则减速,设为2则加速)
onWheel: (self) => {}, // 滚轮回调
// ===== 忽略元素 =====
ignore: ".no-drag", // 选择器:在此元素内的事件被忽略(比如给按钮加 .no-drag)
});self 对象
所有回调函数接收 self 参数,其中包含了当前手势的状态信息:
self = {
deltaX: 10, // 本次事件的X变化量
deltaY: -5, // 本次事件的Y变化量
x: 200, // 当前指针X坐标
y: 150, // 当前指针Y坐标
velocityX: 0.8, // X方向速度
velocityY: 0.3, // Y方向速度
event: event, // 原始事件对象
isDragging: true, // 是否正在拖拽
startX: 100, // 拖拽起始X
startY: 300, // 拖拽起始Y
target: element, // 事件目标
}16.4 关键回调速查
onDrag — 拖拽
deltaX/deltaY 是近期输入变化,不是从按下起累计的距离。保存按下时的元素位置,再加上 self.x-self.startX:
let startX = 0;
const observer = Observer.create({
target: area, type: 'pointer',
onPress() { startX = Number(gsap.getProperty(box, 'x')); },
onDrag(self) {
gsap.set(box, { x: gsap.utils.clamp(0, maxX, startX + self.x - self.startX) });
}
});
// 卸载 observer.kill()。把这一节跑一遍:Observer 拖拽
deltaX 是近期输入增量,x-startX 是这次按下后的累计位移。把 deltaX 直接赋给位置会使元素只能移动一点点;本实验保存按下时的原位置再累计,并用 clamp 限制范围。
实验:Observer 拖拽
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:拖动到左右边界,检查 x 停止但指针仍能移动。再用左右按钮验证边界逻辑;页面正文的竖向滚动保持可用。
onRelease — 释放时判断滑动距离
如需“一次滑动结束后切换页面”,在释放回调中判断指针相对按下位置的位移。方向回调可能在运动中多次触发,不能直接当作一次性 swipe 事件。
Observer.create({
target: ".page",
type: "touch,pointer",
onRelease: (self) => {
const distanceX = self.x - self.startX;
const distanceY = self.y - self.startY;
if (Math.abs(distanceX) < 50 || Math.abs(distanceX) <= Math.abs(distanceY)) return;
gsap.to(".page", {
x: Math.sign(distanceX) * 300,
duration: 0.4,
ease: "power2.out",
onComplete: () => console.log("页面切换完成"),
});
},
});
// 需要实时处理方向变化时,使用方向回调
Observer.create({
type: "touch,pointer",
onLeft: () => console.log("向左运动"),
onRight: () => console.log("向右运动"),
});onPress — 按下
指针按下时触发;需要长按判断时,应另外管理计时器并在释放时清理:
Observer.create({
type: "mouse,touch",
tolerance: 5, // 移动超过5px就不再算"按压"
onPress: (self) => {
gsap.to(".button", { scale: 0.95, duration: 0.1 });
},
onRelease: (self) => {
gsap.to(".button", { scale: 1, duration: 0.1 });
},
});lockAxis — 只让一个方向动
// 自动锁定:第一个方向超过阈值后锁定该轴
Observer.create({
type: "mouse,touch",
lockAxis: true,
onDrag: (self) => console.log("只会沿一个方向移动"),
});
// 水平滑块:业务逻辑只使用 X 位移;lockAxis 不接受字符串 "x"
Observer.create({
type: "mouse,touch",
onDrag: (self) => gsap.set(".slider", { x: self.x - self.startX }),
});16.5 实战示例
示例1:拖拽旋转3D模型预览
在电商产品页上,用户拖拽鼠标旋转查看产品:
<div class="model-viewer" id="modelViewer">
<img src="product.png" class="model-img" alt="产品">
</div>
<style>
.model-viewer { width: 400px; height: 400px; cursor: grab; perspective: 1000px; }
.model-img { width: 100%; height: 100%; object-fit: contain; will-change: transform; }
</style>
<script>
const img = document.querySelector(".model-img");
Observer.create({
target: document.getElementById("modelViewer"),
type: "mouse,touch",
onMove: (self) => {
// 指针移动:小幅跟随倾斜
gsap.to(img, {
rotationY: self.x * 0.3,
rotationX: -self.y * 0.3,
duration: 0.4,
ease: "power2.out",
});
},
onDragStart: (self) => {
// 拖拽开始前保存当前旋转状态
self._rotX = gsap.getProperty(img, "rotationX");
self._rotY = gsap.getProperty(img, "rotationY");
},
onDrag: (self) => {
// 累加旋转
gsap.set(img, {
rotationX: self._rotX - (self.y - self.startY) * 0.5,
rotationY: self._rotY + (self.x - self.startX) * 0.5,
});
},
onDragEnd: (self) => {
// 此处为缓动过渡,真实惯性应交给 InertiaPlugin
gsap.to(img, {
rotationX: self._rotX - (self.y - self.startY) * 0.5,
rotationY: self._rotY + (self.x - self.startX) * 0.5,
duration: 0.8,
ease: "power3.out",
});
},
});
</script>示例2:卡片滑动导航
左右滑动卡片,左划不喜欢,右划喜欢:
<div class="card-stack" id="cardStack">
<div class="swipe-card" style="--i:0">卡片 A</div>
<div class="swipe-card" style="--i:1">卡片 B</div>
<div class="swipe-card" style="--i:2">卡片 C</div>
</div>
<style>
.card-stack { position: relative; width: 300px; height: 400px; margin: 0 auto; }
.swipe-card {
position: absolute; width: 100%; height: 100%;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 20px; display: flex; align-items: center;
justify-content: center; color: white; font-size: 24px;
}
</style>
<script>
const cards = gsap.utils.toArray(".swipe-card");
let currentIndex = 0;
function setupCard(index) {
const card = cards[index];
if (!card) return;
Observer.create({
target: card,
type: "mouse,touch",
dragMinimum: 10,
lockAxis: true, // 锁定主方向
onDrag: (self) => {
const x = self.x - self.startX;
const rotation = x * 0.1;
gsap.set(card, { x, rotation, opacity: 1 - Math.abs(x) / 200 });
},
onDragEnd: (self) => {
const distanceX = self.x - self.startX;
if (Math.abs(distanceX) > 80) {
// 滑出屏幕
const direction = Math.sign(distanceX);
gsap.to(card, {
x: direction * 400,
rotation: direction * 30,
opacity: 0,
duration: 0.3,
onComplete: () => {
card.remove();
currentIndex++;
setupCard(currentIndex); // 激活下一张卡片
},
});
} else {
// 弹回
gsap.to(card, { x: 0, rotation: 0, opacity: 1, duration: 0.3 });
}
},
});
}
setupCard(0);
</script>示例3:鼠标跟踪视差效果
鼠标移动时,不同层次的元素以不同速度跟随,产生深度感:
<div class="parallax-container" id="parallaxContainer">
<div class="layer layer-bg">背景层</div>
<div class="layer layer-mid">中间层</div>
<div class="layer layer-fg">前景层</div>
</div>
<style>
.parallax-container { position: relative; width: 100%; height: 500px; overflow: hidden; }
.layer { position: absolute; width: 120%; height: 120%; top: -10%; left: -10%; }
.layer-bg { background: #1a1a2e; }
.layer-mid { background: radial-gradient(circle, #16213e, transparent); }
.layer-fg { background: radial-gradient(circle at center, #0f3460, transparent); }
</style>
<script>
Observer.create({
type: "pointer",
onMove: (self) => {
// 相对于视口的归一化坐标 (-0.5 ~ 0.5)
const normX = self.x / window.innerWidth - 0.5;
const normY = self.y / window.innerHeight - 0.5;
gsap.to(".layer-bg", {
x: normX * 30, y: normY * 30, duration: 1, ease: "power2.out",
});
gsap.to(".layer-mid", {
x: normX * 60, y: normY * 60, duration: 0.8, ease: "power2.out",
});
gsap.to(".layer-fg", {
x: normX * 100, y: normY * 100, duration: 0.5, ease: "power2.out",
});
},
});
</script>示例4:滚轮驱动横滚动
将垂直滚轮转换为横向滚动:
<div class="horizontal-gallery" id="hGallery">
<div class="gallery-track">
<div class="gallery-item">图1</div>
<div class="gallery-item">图2</div>
<!-- ...更多图片 -->
</div>
</div>
<style>
.horizontal-gallery { overflow: hidden; width: 100%; }
.gallery-track { display: flex; gap: 20px; will-change: transform; }
.gallery-item { flex: 0 0 300px; height: 200px; background: #e74c3c; }
</style>
<script>
const track = document.querySelector(".gallery-track");
const gallery = document.querySelector(".horizontal-gallery");
let offset = 0;
Observer.create({
type: "wheel,touch,pointer",
target: gallery,
preventDefault: true,
wheelSpeed: 0.5,
onChange: (self) => {
// 累积位移,限制范围
const maxScroll = Math.max(0, track.scrollWidth - gallery.clientWidth);
const delta = self.event.type === "wheel" ? -self.deltaY : self.deltaX;
offset = gsap.utils.clamp(-maxScroll, 0, offset + delta);
gsap.to(track, { x: offset, duration: 0.3, ease: "power2.out", overwrite: "auto" });
},
});
</script>16.6 常见陷阱
陷阱1:设备输入与重复注册
Observer 会按支持的底层输入类型进行处理,不能一概断言 type:"touch,pointer" 必定触发两次。官方示例也会组合 wheel/touch/pointer。真正需要检查的是是否对同一元素创建了多个 Observer、是否还保留旧的原生事件监听,以及设备实际支持的输入。
const observer = Observer.create({ target: area, type:'pointer', onDrag: handleDrag });
// 只需指针拖拽时监听对应类型;重新创建前先 observer.kill()。陷阱2:拖拽与页面滚动冲突
拖拽组件放在可滚动页面中时,用户左右拖可能触发了页面上下滚动——体验很差。
// 解决方案1:锁定轴向
Observer.create({
type: "mouse,touch",
lockAxis: true,
preventDefault: true, // 阻止浏览器默认行为
});
// 解决方案2:只在拖拽时阻止页面滚动
Observer.create({
type: "mouse,touch",
onDragStart: () => document.body.style.overflow = "hidden",
onDragEnd: () => document.body.style.overflow = "",
});陷阱3:移动端300ms延迟
虽然现代浏览器已基本解决,但如果遇到点击延迟,在 <head> 中添加:
// 在<head>中添加
<meta name="viewport" content="width=device-width, initial-scale=1">陷阱4:ignore选择器不生效
ignore 在创建时解析目标;动态新增的元素不自动加入该列表。需要动态判断时使用文档支持的 ignoreCheck,而不是把函数放进 ignore:
Observer.create({
target: area, type: 'pointer',
ignoreCheck: event => event.target instanceof Element && !!event.target.closest('.no-drag')
});也可在新元素提交后重新创建 Observer,并清理旧实例。
陷阱5:多Observer实例管理
// 每个Observer占用事件监听器,需要显式销毁
const obs = Observer.create({ type: "wheel", ... });
// 销毁
obs.kill();
// 在React/Vue中使用时,注意在组件卸载时kill
useEffect(() => {
const obs = Observer.create({ ... });
return () => obs.kill();
}, []);每个 Observer 实例都会占用事件监听器,不再使用时应调用 kill() 释放。
陷阱6:缩放/旋转导致坐标偏移
在 transform: scale(0.5) 的父元素中使用 Observer 时,坐标会受到缩放影响。
// 使用 getBoundingClientRect 自行计算补偿
onDrag: (self) => {
const rect = el.getBoundingClientRect();
const scaleX = rect.width / el.offsetWidth;
// 补偿缩放
gsap.set(el, { x: self.deltaX / scaleX, y: self.deltaY / scaleX });
}