走向 "Meta GSAP"。对 "完美 "无限滚动的追求
有一件事常常被GSAP所忽视,那就是你几乎可以用它来制作任何东西的动画。这通常是因为当想到动画时,视觉上的东西会浮现在脑海中。我们的第一个想法并不是把这个过程带到一个Meta的层面,从后退一步开始做动画。
但是,在更大的范围内思考动画工作,然后把它分解成若干层。例如,你播放一部动画片。这部动画片是一个构图的集合。每个构图都是一个场景。然后你有能力用遥控器擦过这些构图的集合,无论是在电脑上,还是用你的电视遥控器,或其他什么。正在发生的事情几乎有三个层次。
这就是我们需要的创造不同类型的无限循环的技巧。这就是这里的主要概念。我们用一条时间线将一个时间线的播放头的位置做成动画。然后我们可以用我们的滚动位置来刷新这个时间线。
如果这听起来很混乱,请不要担心。我们将把它分解开来。
走向 "Meta" 时代
让我们从一个例子开始。我们将创建一个Tween,从左到右移动一些盒子。这就是它。
十个盒子,一直从左到右。这在Greensock中是很直接的。在这里,我们使用fromTo和repeat来保持动画的进行。但是,我们在每个迭代的开始有一个间隙。我们还使用了交错的方式来拉开运动的空间,这一点将在我们继续的过程中发挥重要作用。
gsap.fromTo('.box', {
xPercent: 100
}, {
xPercent: -200,
stagger: 0.5,
duration: 1,
repeat: -1,
ease: 'none',
})
现在,有趣的部分来了。让我们暂停这个Tween,并将其分配给一个变量。然后让我们创建一个播放它的Tween。我们可以通过调整tween的totalTime来做到这一点,这使我们能够获得或设置tween的播放头的tween,同时考虑重复和重复延迟。
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',
})
这是我们的第一个 "Meta" Tween。它看起来完全一样,但我们增加了另一个控制层次。我们可以在不影响原层的情况下改变这一层的东西。例如,我们可以把tween的难易度改为power4.in。这完全改变了动画,但不影响底层动画。我们有点像用后退来保护自己。
不仅如此,我们可能选择只重复时间线的某一部分。我们可以用另一个fromTo做到这一点,就像这样。
这方面的代码将是这样的:
gsap.fromTo(SHIFT, {
totalTime: 2,
}, {
totalTime: DURATION - 1,
repeat: -1,
duration: DURATION,
ease: 'none'
})
你知道这是要去哪里吗?请注意这个间隔。虽然它一直在循环,但每次重复时数字都会翻转。但是,这些方框都在正确的位置。
实现 "完美 "循环
如果我们回到原来的例子,每次重复之间有一个明显的间隙。
诀窍来了。解锁一切的部分。我们需要建立一个完美的循环。
让我们从重复移位三次开始。注意我们是如何从Tween中移除 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())
我们把最初的Tween变成一个函数,返回Tween,并把它添加到一个新的时间轴上,一共三次。这样我们就得到了以下结果。
但是,仍然有一个缺口。现在我们可以引入位置参数,用于添加和定位这些微调。我们希望它是无缝的。这意味着在前一组结束之前插入每一组推子。这是一个基于错开和元素数量的值。
const stagger = 0.5
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)
如果我们更新我们的时间线,重复观看(同时调整错开的时间,看它如何影响事情)......
你会注意到,中间有一个窗口,创造了一个 "无缝 "循环。还记得之前我们操纵时间的那些技巧吗?这就是我们在这里需要做的:在 "无缝 "循环的时间窗口进行循环。
我们可以尝试通过循环的那个窗口对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,
})
在这里,我们说tween的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作为我们例子中的窗口。让我们把盒子的tweens更新为单独的时间线,盒子在进入时放大,退出时缩小。我们可以使用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。值得注意的是,缓和不会产生我们通常认为的效果。事实上,缓和实际上会对盒子的定位起到一定的作用。例如,缓和会使方框在移动到对面之前集中在右边。
上面的代码给了我们这样的结果。
几乎是这样。但是,我们的盒子在中间会消失一段时间。为了解决这个问题,让我们引入 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。BOXES.length。这应该能保护我们不受任何z-index问题的影响。
我们有了它! 我们的第一个无限的无缝循环。没有重复的元素和完美的延续性。我们正在弯曲时间! 如果你已经走到了这一步,请拍拍自己的胸脯吧! 🎉
如果我们想一次看到更多的盒子,我们可以对时间、交错和缓和进行调整。在这里,我们的STAGGER是0.2,而且我们还把不透明度引入了混合。
这里的关键部分是,我们可以利用repeatDelay,使不透明度的过渡比刻度更快。在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。这需要一个额外的Tween来刷新我们的循环窗口。请注意,我们现在也将循环设置为暂停状态。
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来刷新循环的播放头。我们可以用各种方式来设置这个滚动。我们可以让它处于水平状态,或者与一个容器绑定。但是,我们要做的是将我们的盒子包裹在一个.box元素中,并将其固定在视口上。(这样可以固定它在视口中的位置。)我们也将坚持使用垂直滚动。查看演示,看看.box的样式,它将东西设置为视口的大小。
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里面。这就是我们根据滚动进度来设置tween的totalTime的地方。invalidate调用将刷新任何内部记录的位置。然后重启将位置设置为我们设置的新totalTime。
试试吧! 我们可以在时间轴上来回走动并更新位置。
这多酷啊?我们可以滚动刷新一个时间线,刷新一个时间线,这个时间线是一个时间线的窗口。稍微消化一下,因为这就是这里发生的事情。
无限滚动的时间之旅
到现在为止,我们一直在操纵时间。现在,我们要进行时间旅行了
为了做到这一点,我们将使用一些其他的GSAP工具,而且我们将不再刷新LOOP_HEAD的totalTime。相反,我们将通过代理来更新它。这是另一个 "Meta" GSAP的好例子。
让我们从一个标记播放头位置的代理对象开始。
const PLAYHEAD = { position: 0 }
现在我们可以更新我们的SCRUB来更新位置。同时,我们可以使用GSAP的wrap工具,将位置值包裹在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上的正确变量。那就是位置,而不是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,以此类推。同时,我们将跟踪一个迭代变量,因为它告诉我们在刷新过程中的位置。我们还将确保只有在我们达到进度阈值时才更新迭代。
让我们从一个迭代变量开始:
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) {
WRAP(1, 1)
} else if (SCROLL < 1 && self.direction <; 0) {
WRAP(-1, self.end - 1)
} else {
SCRUB.vars.position = (iteration + self.progress) * LOOP_HEAD.duration()
SCRUB.invalidate().restart()
}
}
})
注意我们现在是如何将迭代的因素纳入位置计算的。请记住,这将与洗涤器一起被包裹起来。我们也在检测我们何时达到了滚动的极限,这就是我们WRAP的地方。这个函数设置适当的迭代值,并设置新的滚动位置。
const WRAP = (iterationDelta, scrollTo) => {
iteration += iterationDelta
TRIGGER.scroll(scrollTo)
TRIGGER.update()
}
我们有无限的滚动功能! 如果你有那种带滚轮的花哨的鼠标,你可以放开手脚,给它一个机会! 这很有趣!
这里有一个显示当前迭代和进度的演示:
滚动
我们在那里。但是,在开发这样的功能时,总是有一些 "不错的选择"。让我们从滚动抓取开始。GSAP让这个问题变得简单,因为我们可以使用gsap.utils.snap而不需要任何其他依赖。这就处理了我们提供的时间点的抢购。我们声明步长在0到1之间,在我们的例子中我们有10个盒子。这意味着0.1的快门对我们来说是可行的。
const SNAP = gsap.utils.snap(1 / BOXES.length)
它返回一个函数,我们可以用它来捕捉我们的位置值。
我们只想在滚动结束后进行抓取。为此,我们可以在ScrollTrigger上使用一个事件监听器。当滚动结束时,我们将滚动到一个特定的位置。
ScrollTrigger.addEventListener('scrollEnd', () => {
scrollToPosition(SCRUB.vars.position)
})
而这里是rollToPosition。
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的迭代,其中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))
// 左、右箭头加A和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的一些很棒的实用工具来为我们处理逻辑。
你现在可以操纵时间了! 😅
这种 "Meta" GSAP的概念开启了各种可能性。你还可以做什么动画?音频?视频?至于 "封面流 "演示,这就是它的结果