返回目录

从补间到叙事: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/pointeruptouch和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 });
}