迈向“元 GSAP”:追求“完美”的无限滚动

Avatar of Jhey Tompkins
Jhey Tompkins

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费积分!

我不确定这个想法是怎么来的。但它确实是一个故事。这篇文章更多的是关于理解一个概念,一个将帮助你以不同的方式思考动画的概念。碰巧的是,这个具体的例子展示了无限滚动——特别是对于一叠卡片来说,在不重复任何卡片的情况下实现“完美”的无限滚动。

我为什么要在这里?好吧,这一切都始于一条推文。一条让我开始思考布局和横向滚动内容的推文。

我采用了这个概念,并在 我的网站 上使用它。在撰写本文时,它仍然在那里运行。

然后我开始更多地思考画廊视图和横向滚动概念。我们跳上了 直播 并决定尝试制作一些类似于旧的 Apple “Cover Flow” 模式的东西。 记得它吗?

我最初的想法是让它在没有 JavaScript 的情况下工作,就像上面的演示一样,使用“渐进增强”的方式。我抓住了 GreensockScrollTrigger,然后我们就开始了。我从那项工作中走了出来,感到非常失望。我做了一些事情,但无法按照我想要的方式让无限滚动工作。 “下一步”和“上一步”按钮不想配合。你可以在此处看到它,它需要横向滚动。

因此,我在 Greensock 论坛上开了一个 新主题。我当时并不知道自己将要开始进行一些认真的学习!我们解决了按钮的问题。但是,由于是我的个性,我不得不问是否还有其他可能。有没有一种“干净”的方式来进行无限滚动?我在直播中尝试过,但没有成功。我很好奇。我尝试过一种类似于我在 ScrollTrigger 发布时为它创建的这款笔中所使用的技术。

最初的 答案 是,这样做有点棘手

无限滚动中最难的部分是滚动条是有限的,而你想要的滚动效果是无限的。因此,你必须像 这个演示(在 ScrollTrigger 演示部分 中找到)一样循环滚动位置,或者直接挂钩到滚动相关的导航事件(如轮子事件),而不是真正使用实际的滚动位置。

我认为情况就是这样,很高兴让它“保持原样”。过了几天,Jack 回复 了一个答案,当我开始深入研究它时,它让我大吃一惊。现在,经过一番研究之后,我在这里与大家分享这种技术。

动画任何东西

GSAP 经常被忽视的一点是,你可以用它来动画几乎任何东西。这通常是因为在思考动画时,视觉上的东西首先浮现在脑海中——某物的实际物理运动。我们的第一个想法不是将这个过程提升到一个元层级,并从后退一步的角度进行动画处理。

但是,想想更大规模的动画作品,然后将其分解成不同的层次。例如,你播放一部卡通片。卡通片是一组组合。每个组合都是一个场景。然后,你可以使用遥控器来浏览这组组合,无论是在 YouTube 上,还是使用电视遥控器,还是其他任何方式。几乎有三个层次的东西在发生。

这就是我们创建**不同类型的无限循环**所需要的技巧。这就是这里的核心概念。我们使用一个时间轴来动画处理另一个时间轴的播放头位置。然后,我们可以使用我们的滚动位置来滚动该时间轴。

如果这听起来很混乱,别担心。我们将把它分解。

走向“元”

让我们从一个例子开始。我们将创建一个动画,将一些盒子从左移到右。它在这里。

十个盒子不断地从左移到右。使用 Greensock 来实现这一点非常简单。在这里,我们使用fromTorepeat来让动画持续进行。但是,在每次迭代的开始处,我们都存在一个间隙。我们还使用stagger来间隔运动,这将在我们继续进行时发挥重要作用。

gsap.fromTo('.box', {
  xPercent: 100
}, {
  xPercent: -200,
  stagger: 0.5,
  duration: 1,
  repeat: -1,
  ease: 'none',
})

现在是乐趣的部分。让我们暂停动画并将它分配给一个变量。然后让我们创建一个播放它的动画。我们可以通过对动画的totalTime进行动画处理来做到这一点,这允许我们获取或设置动画的播放头动画,同时考虑重复和重复延迟。

const SHIFT = gsap.fromTo('.box', {
  xPercent: 100
}, {
  paused: true,
  xPercent: -200,
  stagger: 0.5,
  duration: 1,
  repeat: -1,
  ease: 'none',
})

const DURATION = SHIFT.duration()

gsap.to(SHIFT, {
  totalTime: DURATION,
  repeat: -1,
  duration: DURATION,
  ease: 'none',
})

这是我们的第一个“元”动画。它看起来完全一样,但我们添加了另一个控制层。我们可以更改此层的某些内容,而不会影响原始层。例如,我们可以将动画的ease更改为power4.in。这会完全改变动画,但不会影响底层动画。我们通过某种方式保护了自身,使其具有回退功能。

不仅如此,我们还可以选择仅重复时间轴的某个部分。我们可以使用另一个fromTo来实现,就像这样

相应的代码将类似于:

gsap.fromTo(SHIFT, {
  totalTime: 2,
}, {
  totalTime: DURATION - 1,
  repeat: -1,
  duration: DURATION,
  ease: 'none'
})

你看到它要去哪里了吗?观看那个动画。虽然它一直在循环,但数字在每次重复时都会翻转。但是,盒子位于正确的位置。

实现“完美”循环

如果我们回到最初的例子,你会注意到每次重复之间存在明显的间隙。

现在是关键所在。解开一切的关键部分。我们需要构建一个完美的循环。

让我们从将移位重复三次开始。它等同于使用repeat: 3。请注意,我们已从动画中删除了repeat: -1

const getShift = () => gsap.fromTo('.box', {
  xPercent: 100
}, {
  xPercent: -200,
  stagger: 0.5,
  duration: 1,
  ease: 'none',
})

const LOOP = gsap.timeline()
  .add(getShift())
  .add(getShift())
  .add(getShift())

我们将初始动画变成了一个返回动画的函数,并将其添加到一个新的时间轴中三次。这将给我们以下结果。

好吧。但是,仍然存在间隙。现在我们可以引入position参数来添加和定位这些动画。我们希望它是无缝的。这意味着在每个动画集结束之前插入它。这个值基于stagger和元素的数量。

const stagger = 0.5 // Used in our shifting tween
const BOXES = gsap.utils.toArray('.box')
const LOOP = gsap.timeline({
  repeat: -1
})
  .add(getShift(), 0)
  .add(getShift(), BOXES.length * stagger)
  .add(getShift(), BOXES.length * stagger * 2)

如果我们更新时间轴以重复并观察它(同时调整stagger以查看它如何影响事情)……

你会注意到,中间有一个窗口,它创建了一个“无缝”循环。还记得我们之前操作时间的那段技能吗?这就是我们现在需要做的:循环时间轴中循环“无缝”的时间窗口。

我们可以尝试通过该循环窗口来动画处理totalTime

const LOOP = gsap.timeline({
  paused: true,
  repeat: -1,
})
.add(getShift(), 0)
.add(getShift(), BOXES.length * stagger)
.add(getShift(), BOXES.length * stagger * 2)

gsap.fromTo(LOOP, {
  totalTime: 4.75,
},
{
  totalTime: '+=5',
  duration: 10,
  ease: 'none',
  repeat: -1,
})

在这里,我们说将totalTime4.75动画处理,并在此基础上添加一个循环的长度。循环的长度是5。这就是时间轴的中间窗口。我们可以使用 GSAP 的巧妙的+=来做到这一点,这将给我们以下结果

花点时间消化一下那里发生了什么。这可能是最难理解的部分。我们正在时间轴中计算时间窗口。这有点难以想象,但我尝试了一下。

这是一个手表演示,指针转一圈需要 12 秒。它使用repeat: -1无限循环,然后我们使用fromTo来动画处理给定持续时间内的特定时间窗口。如果你将时间窗口缩短到例如26,然后将持续时间更改为1,指针将反复从 2 点钟转到 6 点钟。但是,我们并没有改变底层动画。

尝试配置这些值以查看它如何影响事情。

此时,最好为我们的窗口位置制定一个公式。我们也可以使用一个变量来表示每个盒子过渡所花费的持续时间。

const DURATION = 1
const CYCLE_DURATION = BOXES.length * STAGGER
const START_TIME = CYCLE_DURATION + (DURATION * 0.5)
const END_TIME = START_TIME + CYCLE_DURATION

与其使用三个堆叠的时间轴,我们可以在元素上循环三次,这样就不需要计算位置。尽管将它可视化为三个堆叠的时间轴是一种理解这个概念的好方法,并且可以帮助理解主要思想。

让我们更改我们的实现,从一开始就创建一个大型时间轴。

const STAGGER = 0.5
const BOXES = gsap.utils.toArray('.box')

const LOOP = gsap.timeline({
  paused: true,
  repeat: -1,
})

const SHIFTS = [...BOXES, ...BOXES, ...BOXES]

SHIFTS.forEach((BOX, index) => {
  LOOP.fromTo(BOX, {
    xPercent: 100
  }, {
    xPercent: -200,
    duration: 1,
    ease: 'none',
  }, index * STAGGER)
})

这更容易组合,并且可以提供相同的窗口。但是,我们不需要考虑数学运算。现在,我们循环遍历三组盒子,并根据错开值定位每个动画。

如果我们调整错开值,它会是什么样子?它将把盒子挤得更靠近。

但是,它破坏了窗口,因为现在的totalTime不对了。我们需要重新计算窗口。现在是插入我们之前计算出的公式的好时机。

const DURATION = 1
const CYCLE_DURATION = STAGGER * BOXES.length
const START_TIME = CYCLE_DURATION + (DURATION * 0.5)
const END_TIME = START_TIME + CYCLE_DURATION

gsap.fromTo(LOOP, {
  totalTime: START_TIME,
},
{
  totalTime: END_TIME,
  duration: 10,
  ease: 'none',
  repeat: -1,
})

修复了!

如果我们想更改起始位置,甚至可以引入一个“偏移量”。

const STAGGER = 0.5
const OFFSET = 5 * STAGGER
const START_TIME = (CYCLE_DURATION + (STAGGER * 0.5)) + OFFSET

现在,我们的窗口从不同的位置开始。

但是,这仍然不是很好,因为它会在每端给我们带来这些笨拙的堆叠。要消除这种效果,我们需要考虑一个“物理”窗口,用于我们的框。或者考虑它们如何进入和退出场景。

我们将使用document.body作为示例的窗口。让我们更新框补间,使其成为单个时间轴,框在进入时放大,在退出时缩小。我们可以使用yoyorepeat: 1来实现进入和退出。

SHIFTS.forEach((BOX, index) => {
  const BOX_TL = gsap
    .timeline()
    .fromTo(
      BOX,
      {
        xPercent: 100,
      },
      {
        xPercent: -200,
        duration: 1,
        ease: 'none',
      }, 0
    )
    .fromTo(
      BOX,
      {
        scale: 0,
      },
      {
        scale: 1,
        repeat: 1,
        yoyo: true,
        ease: 'none',
        duration: 0.5,
      },
      0
    )
  LOOP.add(BOX_TL, index * STAGGER)
})

为什么我们要使用1的时间轴持续时间?这使得事情更容易遵循。我们知道当框处于中点时,时间为0.5。值得注意的是,缓动在这里不会产生我们通常认为的效果。事实上,缓动实际上会在框如何定位方面发挥作用。例如,ease-in会将框在右侧集中在一起,然后它们才会移动。

上面的代码为我们提供了以下内容。

几乎。但是,我们的框在中间有一段时间会消失。为了解决这个问题,让我们介绍immediateRender属性。它的作用类似于 CSS 中的animation-fill-mode: none。我们告诉 GSAP 我们不想保留或预先记录对框进行的任何样式设置。

SHIFTS.forEach((BOX, index) => {
  const BOX_TL = gsap
    .timeline()
    .fromTo(
      BOX,
      {
        xPercent: 100,
      },
      {
        xPercent: -200,
        duration: 1,
        ease: 'none',
        immediateRender: false,
      }, 0
    )
    .fromTo(
      BOX,
      {
        scale: 0,
      },
      {
        scale: 1,
        repeat: 1,
        zIndex: BOXES.length + 1,
        yoyo: true,
        ease: 'none',
        duration: 0.5,
        immediateRender: false,
      },
      0
    )
  LOOP.add(BOX_TL, index * STAGGER)
})

这个小小的改变就为我们解决了问题!请注意,我们还包含了z-index: BOXES.length。这应该可以保护我们免受任何z-index问题的影响。

就是这样!我们的第一个无限无缝循环。没有重复元素,完美延续。我们正在扭曲时间!如果你已经走到了这一步,就给自己鼓鼓掌吧!🎉

如果我们想要一次看到更多的框,我们可以调整时间、交错和缓动。这里,我们有一个STAGGER0.2,并且我们还引入了opacity到混合中。

这里关键的部分是,我们可以利用repeatDelay,使opacity过渡比缩放更快。在 0.25 秒内淡入。等待 0.5 秒。在 0.25 秒内淡出。

.fromTo(
  BOX, {
    opacity: 0,
  }, {
    opacity: 1,
    duration: 0.25,
    repeat: 1,
    repeatDelay: 0.5,
    immediateRender: false,
    ease: 'none',
    yoyo: true,
  }, 0)

酷!我们可以对那些进出过渡做任何我们想做的事情。这里主要的是我们有自己的时间窗口,它给了我们无限循环。

将它与滚动连接

现在我们有了无缝循环,让我们将其附加到滚动。为此,我们可以使用 GSAP 的 ScrollTrigger。这需要一个额外的补间来擦除我们的循环窗口。请注意,我们现在也已将循环设置为paused

const LOOP_HEAD = gsap.fromTo(LOOP, {
  totalTime: START_TIME,
},
{
  totalTime: END_TIME,
  duration: 10,
  ease: 'none',
  repeat: -1,
  paused: true,
})

const SCRUB = gsap.to(LOOP_HEAD, {
  totalTime: 0,
  paused: true,
  duration: 1,
  ease: 'none',
})

这里的诀窍是使用ScrollTrigger通过更新SCRUBtotalTime来擦除循环的播放头位置。我们可以通过多种方式设置此滚动。我们可以将其设置为水平滚动,或绑定到容器。但是,我们要做的是将我们的框包装在一个.boxes元素中,并将该元素固定到视窗。(这会将其在视窗中的位置固定。)我们也将坚持垂直滚动。查看演示以查看.boxes的样式,它将内容设置为视窗的大小。

import ScrollTrigger from 'https://cdn.skypack.dev/gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)

ScrollTrigger.create({
  start: 0,
  end: '+=2000',
  horizontal: false,
  pin: '.boxes',
  onUpdate: self => {
    SCRUB.vars.totalTime = LOOP_HEAD.duration() * self.progress
    SCRUB.invalidate().restart()
  }
})

重要的是onUpdate中的内容。那就是我们根据滚动进度设置补间totalTime的地方。invalidate调用会清除擦除内部记录的任何位置。然后restart将位置设置为我们设置的新的totalTime

试试看!我们可以在时间轴中来回移动,并更新位置。

这有多酷?我们可以滚动擦除一个时间轴,该时间轴擦除一个时间轴,该时间轴是另一个时间轴的窗口。仔细思考一下,因为这就是这里发生的事情。

时间旅行以实现无限滚动

到目前为止,我们一直在操纵时间。现在我们要进行时间旅行!

为此,我们将使用一些其他的 GSAP 实用程序,并且不再擦除LOOP_HEADtotalTime。相反,我们将通过代理更新它。这是另一个关于“元”GSAP 的很棒例子。

让我们从一个标记播放头位置的代理对象开始。

const PLAYHEAD = { position: 0 }

现在我们可以更新我们的SCRUB来更新position。同时,我们可以使用 GSAP 的wrap实用程序,该实用程序将position值围绕LOOP_HEAD持续时间包装。例如,如果持续时间为10,我们提供的值为11,我们将得到1

const POSITION_WRAP = gsap.utils.wrap(0, LOOP_HEAD.duration())

const SCRUB = gsap.to(PLAYHEAD, {
  position: 0,
  onUpdate: () => {
    LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))
  },
  paused: true,
  duration: 1,
  ease: 'none',
})

最后但并非最不重要的一点是,我们需要修改 ScrollTrigger,使其在SCRUB上更新正确的变量。那是position,而不是totalTime

ScrollTrigger.create({
  start: 0,
  end: '+=2000',
  horizontal: false,
  pin: '.boxes',
  onUpdate: self => {
    SCRUB.vars.position = LOOP_HEAD.duration() * self.progress
    SCRUB.invalidate().restart()
  }
})

在这一点上,我们已经切换到代理,并且不会看到任何变化。

当我们滚动时,我们想要一个无限循环。我们的第一个想法可能是当我们完成滚动进度时滚动到开头。它会完全做到这一点,滚动回去。虽然这是我们想要做的,但我们不想让播放头倒着擦除。这就是totalTime发挥作用的地方。还记得吗?它根据totalDuration获取或设置播放头的位置,其中包括任何重复和重复延迟。

例如,假设循环头的持续时间为5,我们已经到达那里,我们不会擦除回 0。相反,我们将继续擦除循环头到10。如果我们继续下去,它将变为15,依此类推。同时,我们将跟踪一个iteration变量,因为它告诉我们我们在擦除中的位置。我们还将确保仅在到达进度阈值时才更新iteration

让我们从一个iteration变量开始

let iteration = 0

现在让我们更新我们的 ScrollTrigger 实现

const TRIGGER = ScrollTrigger.create({
  start: 0,
  end: '+=2000',
  horizontal: false,
  pin: '.boxes',
  onUpdate: self => {
    const SCROLL = self.scroll()
    if (SCROLL > self.end - 1) {
      // Go forwards in time
      WRAP(1, 1)
    } else if (SCROLL < 1 && self.direction <; 0) {
      // Go backwards in time
      WRAP(-1, self.end - 1)
    } else {
      SCRUB.vars.position = (iteration + self.progress) * LOOP_HEAD.duration()
      SCRUB.invalidate().restart() 
    }
  }
})

请注意,我们现在如何将iteration纳入position计算中。请记住,它与擦除器一起包装。我们还在检测何时到达滚动的限制,那是我们WRAP的地方。此函数设置适当的iteration值并设置新的滚动位置。

const WRAP = (iterationDelta, scrollTo) => {
  iteration += iterationDelta
  TRIGGER.scroll(scrollTo)
  TRIGGER.update()
}

我们有了无限滚动!如果你有一个带滚动轮的那些花哨的鼠标,可以随意使用!很有趣!

这是一个演示,它显示当前的iterationprogress

滚动捕捉

我们已经到了那里。但是,在完成此类功能时,总有一些“好的方面”。让我们从滚动捕捉开始。GSAP 使这变得很容易,因为我们可以使用gsap.utils.snap而无需任何其他依赖项。当我们提供点时,它会处理捕捉到时间。我们声明了01之间的步长,并且我们的示例中有10个框。这意味着0.1的捕捉对我们来说很有效。

const SNAP = gsap.utils.snap(1 / BOXES.length)

这会返回一个我们可以用来捕捉position值的函数。

我们只希望在滚动结束时捕捉一次。为此,我们可以使用 ScrollTrigger 上的事件监听器。当滚动结束时,我们将滚动到某个position

ScrollTrigger.addEventListener('scrollEnd', () => {
  scrollToPosition(SCRUB.vars.position)
})

这是scrollToPosition

const scrollToPosition = position => {
  const SNAP_POS = SNAP(position)
  const PROGRESS =
    (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()
  const SCROLL = progressToScroll(PROGRESS)
  TRIGGER.scroll(SCROLL)
}

我们在这里做了什么?

  1. 计算要捕捉到的时间点
  2. 计算当前进度。假设LOOP_HEAD.duration()1,我们已捕捉到2.5。这会给我们一个0.5的进度,导致一个2iteration,其中2.5 - 1 * 2 / 1 === 0.5。我们计算进度,使其始终在10之间。
  3. 计算滚动目标。这是 ScrollTrigger 可以覆盖的距离的一部分。在我们的示例中,我们设置了2000的距离,我们想要该距离的一部分。我们创建了一个新的函数progressToScroll来计算它。
const progressToScroll = progress =>
  gsap.utils.clamp(1, TRIGGER.end - 1, gsap.utils.wrap(0, 1, progress) * TRIGGER.end)

此函数采用进度值并将其映射到最大的滚动距离。但我们使用一个钳位,以确保值永远不能为02000。这很重要。我们正在防止捕捉到这些值,因为这会导致我们陷入无限循环。

那里有一些需要了解的内容。查看此演示,它显示了每次捕捉时的更新值。

为什么东西更敏捷了?擦除持续时间和缓动已更改。更小的持续时间和更强劲的缓动会给我们捕捉效果。

const SCRUB = gsap.to(PLAYHEAD, {
  position: 0,
  onUpdate: () => {
    LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))
  },
  paused: true,
  duration: 0.25,
  ease: 'power3',
})

但是,如果你玩过那个演示,你会注意到有一个问题。有时当我们在捕捉中绕回时,播放头会跳动。我们需要通过确保在捕捉时进行绕回(但只有在必要时)来解决这个问题。

const scrollToPosition = position => {
  const SNAP_POS = SNAP(position)
  const PROGRESS =
    (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()
  const SCROLL = progressToScroll(PROGRESS)
  if (PROGRESS >= 1 || PROGRESS < 0) return WRAP(Math.floor(PROGRESS), SCROLL)
  TRIGGER.scroll(SCROLL)
}

现在我们有了带有捕捉功能的无限滚动!

下一步是什么?

我们已经完成了坚实无限滚动的基础工作。我们可以利用它来添加一些东西,比如控件或键盘功能。例如,这可能是一种连接“下一个”和“上一个”按钮以及键盘控件的方法。我们所要做的就是操纵时间,对吧?

const NEXT = () => scrollToPosition(SCRUB.vars.position - (1 / BOXES.length))
const PREV = () => scrollToPosition(SCRUB.vars.position + (1 / BOXES.length))

// Left and Right arrow plus A and D
document.addEventListener('keydown', event => {
  if (event.keyCode === 37 || event.keyCode === 65) NEXT()
  if (event.keyCode === 39 || event.keyCode === 68) PREV()
})

document.querySelector('.next').addEventListener('click', NEXT)
document.querySelector('.prev').addEventListener('click', PREV)

这可能会给我们带来类似的东西。

我们可以利用我们的scrollToPosition函数,并在需要时增加该值。

就是这样!

看到了吗?GSAP 不仅可以动画化元素!在这里,我们弯曲并操纵时间来创建几乎完美的无限滑块。没有重复的元素,没有混乱,并且具有良好的灵活性。

让我们回顾一下我们所涵盖的内容

  • 我们可以动画化动画。🤯
  • 当我们操纵时间时,我们可以将时间视为定位工具。
  • 如何使用 ScrollTrigger 通过代理擦除动画。
  • 如何使用 GSAP 的一些强大的实用程序来为我们处理逻辑。

你现在可以操纵时间了!😅

这种“元” GSAP 的概念开辟了各种可能性。你还能动画化什么?音频?视频?至于“Cover Flow”演示,这就是它去的地方!