我不确定这个想法是怎么来的。但它确实是一个故事。这篇文章更多的是关于理解一个概念,一个将帮助你以不同的方式思考动画的概念。碰巧的是,这个具体的例子展示了无限滚动——特别是对于一叠卡片来说,在不重复任何卡片的情况下实现“完美”的无限滚动。
我为什么要在这里?好吧,这一切都始于一条推文。一条让我开始思考布局和横向滚动内容的推文。
我采用了这个概念,并在 我的网站 上使用它。在撰写本文时,它仍然在那里运行。
然后我开始更多地思考画廊视图和横向滚动概念。我们跳上了 直播 并决定尝试制作一些类似于旧的 Apple “Cover Flow” 模式的东西。 记得它吗?
我最初的想法是让它在没有 JavaScript 的情况下工作,就像上面的演示一样,使用“渐进增强”的方式。我抓住了 Greensock 和 ScrollTrigger,然后我们就开始了。我从那项工作中走了出来,感到非常失望。我做了一些事情,但无法按照我想要的方式让无限滚动工作。 “下一步”和“上一步”按钮不想配合。你可以在此处看到它,它需要横向滚动。
因此,我在 Greensock 论坛上开了一个 新主题。我当时并不知道自己将要开始进行一些认真的学习!我们解决了按钮的问题。但是,由于是我的个性,我不得不问是否还有其他可能。有没有一种“干净”的方式来进行无限滚动?我在直播中尝试过,但没有成功。我很好奇。我尝试过一种类似于我在 ScrollTrigger 发布时为它创建的这款笔中所使用的技术。
最初的 答案 是,这样做有点棘手
无限滚动中最难的部分是滚动条是有限的,而你想要的滚动效果是无限的。因此,你必须像 这个演示(在 ScrollTrigger 演示部分 中找到)一样循环滚动位置,或者直接挂钩到滚动相关的导航事件(如轮子事件),而不是真正使用实际的滚动位置。
我认为情况就是这样,很高兴让它“保持原样”。过了几天,Jack 回复 了一个答案,当我开始深入研究它时,它让我大吃一惊。现在,经过一番研究之后,我在这里与大家分享这种技术。
动画任何东西
GSAP 经常被忽视的一点是,你可以用它来动画几乎任何东西。这通常是因为在思考动画时,视觉上的东西首先浮现在脑海中——某物的实际物理运动。我们的第一个想法不是将这个过程提升到一个元层级,并从后退一步的角度进行动画处理。
但是,想想更大规模的动画作品,然后将其分解成不同的层次。例如,你播放一部卡通片。卡通片是一组组合。每个组合都是一个场景。然后,你可以使用遥控器来浏览这组组合,无论是在 YouTube 上,还是使用电视遥控器,还是其他任何方式。几乎有三个层次的东西在发生。
这就是我们创建**不同类型的无限循环**所需要的技巧。这就是这里的核心概念。我们使用一个时间轴来动画处理另一个时间轴的播放头位置。然后,我们可以使用我们的滚动位置来滚动该时间轴。
如果这听起来很混乱,别担心。我们将把它分解。
走向“元”
让我们从一个例子开始。我们将创建一个动画,将一些盒子从左移到右。它在这里。
十个盒子不断地从左移到右。使用 Greensock 来实现这一点非常简单。在这里,我们使用fromTo
和repeat
来让动画持续进行。但是,在每次迭代的开始处,我们都存在一个间隙。我们还使用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,
})
在这里,我们说将totalTime
从4.75
动画处理,并在此基础上添加一个循环的长度。循环的长度是5
。这就是时间轴的中间窗口。我们可以使用 GSAP 的巧妙的+=
来做到这一点,这将给我们以下结果
花点时间消化一下那里发生了什么。这可能是最难理解的部分。我们正在时间轴中计算时间窗口。这有点难以想象,但我尝试了一下。
这是一个手表演示,指针转一圈需要 12 秒。它使用repeat: -1
无限循环,然后我们使用fromTo
来动画处理给定持续时间内的特定时间窗口。如果你将时间窗口缩短到例如2
和6
,然后将持续时间更改为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
作为示例的窗口。让我们更新框补间,使其成为单个时间轴,框在进入时放大,在退出时缩小。我们可以使用yoyo
和repeat: 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
问题的影响。
就是这样!我们的第一个无限无缝循环。没有重复元素,完美延续。我们正在扭曲时间!如果你已经走到了这一步,就给自己鼓鼓掌吧!🎉
如果我们想要一次看到更多的框,我们可以调整时间、交错和缓动。这里,我们有一个STAGGER
为0.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
通过更新SCRUB
的totalTime
来擦除循环的播放头位置。我们可以通过多种方式设置此滚动。我们可以将其设置为水平滚动,或绑定到容器。但是,我们要做的是将我们的框包装在一个.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_HEAD
的totalTime
。相反,我们将通过代理更新它。这是另一个关于“元”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()
}
我们有了无限滚动!如果你有一个带滚动轮的那些花哨的鼠标,可以随意使用!很有趣!
这是一个演示,它显示当前的iteration
和progress
滚动捕捉
我们已经到了那里。但是,在完成此类功能时,总有一些“好的方面”。让我们从滚动捕捉开始。GSAP 使这变得很容易,因为我们可以使用gsap.utils.snap
而无需任何其他依赖项。当我们提供点时,它会处理捕捉到时间。我们声明了0
和1
之间的步长,并且我们的示例中有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)
}
我们在这里做了什么?
- 计算要捕捉到的时间点
- 计算当前进度。假设
LOOP_HEAD.duration()
为1
,我们已捕捉到2.5
。这会给我们一个0.5
的进度,导致一个2
的iteration
,其中2.5 - 1 * 2 / 1 === 0.5
。我们计算进度,使其始终在1
和0
之间。 - 计算滚动目标。这是 ScrollTrigger 可以覆盖的距离的一部分。在我们的示例中,我们设置了
2000
的距离,我们想要该距离的一部分。我们创建了一个新的函数progressToScroll
来计算它。
const progressToScroll = progress =>
gsap.utils.clamp(1, TRIGGER.end - 1, gsap.utils.wrap(0, 1, progress) * TRIGGER.end)
此函数采用进度值并将其映射到最大的滚动距离。但我们使用一个钳位,以确保值永远不能为0
或2000
。这很重要。我们正在防止捕捉到这些值,因为这会导致我们陷入无限循环。
那里有一些需要了解的内容。查看此演示,它显示了每次捕捉时的更新值。
为什么东西更敏捷了?擦除持续时间和缓动已更改。更小的持续时间和更强劲的缓动会给我们捕捉效果。
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”演示,这就是它去的地方!
很棒的文章。我总是对 GSAP 的强大功能感到惊讶。
GSAP 规则 ;) 我认为是最好的动画库,必备 - 对所有人,适用于所有人。我推荐 Y 上的频道。 #snorklTV
太棒了,太棒了,太棒了。
我看到有人在 twitch 上使用这种案例,来构建一个普通的图像垂直无限滚动。疯狂的是你可以向上滚动。没有起点。
不可能的事变成了可能。页面向上无限滚动。
我用它做了一个 codepen。
我发现了 gsap 的这些实用程序函数,我肯定会用它们来动画化数字,而不仅仅是 DOM 元素。
我的大脑有点融化了。干得好。这应该是一个独立的插件,如果我需要它,我不想手动重新创建这段代码。
希望这篇文章会更新,一些例子已经过时了,会导致一些奇怪的故障