likes
comments
collection

练习动画最好的方式:用GSAP实现可滚动和可拖动的时间轴

作者站长头像
站长
· 阅读数 13

Greensock 动画库的 ScrollTrigger 和 Draggable 插件可以帮助我们创建一些非常酷的响应用户交互的效果。在本教程中,我们将了解如何将它们一起使用,以创建一个可滚动和可拖动的交互式时间轴。

我们将建立一个时间轴,显示周董的发行的专辑。我们时间轴的主题并不重要——主要是在多个日期发生的一系列事件——所以随意选择你自己的主题,让它对你来说更个性化!

我们的网页顶部有一个时间轴,显示我们的日期,以及一些全角部分,我们每个日期的内容都将在其中存在。拖动水平时间轴应该将页面滚动到内容中的适当位置,同样滚动页面会导致我们的时间轴更新。此外,单击时间轴中的任何链接将允许用户直接跳转到相关部分。这意味着我们有三种不同的方法来导航我们的页面——它们都必须相互完美同步。

练习动画最好的方式:用GSAP实现可滚动和可拖动的时间轴

我们将逐步完成创建时间轴的步骤。

HTML

由于这将是我们的主页面,我将使用 <nav> 元素。在其中,我们有一个marker,我们将使用 CSS 设置样式以指示时间轴上的位置。我们还有一个带有 nav__track 类的 <div>,这将是我们的可拖动触发器。它包含我们的导航链接列表。

<nav>
	<!--Shows our position on the timeline-->
	<div class="marker"></div>
	
	<!--Draggable element-->
	<div class="nav__track" data-draggable>
		<ul class="nav__list">
			<li>
				<a href="#section_1" class="nav__link" data-link><span>2000</span></a>
			</li>
			<li>
				<a href="#section_2" class="nav__link" data-link><span>2001</span></a>
			</li>
			<li>
				<a href="#section_3" class="nav__link" data-link><span>2002</span></a>
			</li>
			<!--More list items go here-->
		</ul>
	</div>
</nav>

在我们的导航下方,我们有页面的主要内容,其中包括许多部分。我们将为每一个部分分配一个与导航中的链接之一相对应的 id。这样,当用户单击链接时,他们将滚动到内容中的相关位置——无需 JS。

我们还将为每个部分设置一个与该部分索引相对应的自定义属性。这是可选的,但对样式很有用。我们暂时不用担心我们部分的内容。

<main>
	<section id="section_1" style="--i: 0"></section>
	<section id="section_2" style="--i: 1"></section>
	<section id="section_3" style="--i: 2"></section>
	<!--……-->
</main>

CSS

接下来我们将进入我们的基本布局。我们将给每个部分的最小高度为 100vh。我们还可以给它们一个背景颜色,以便在我们滚动浏览这些部分时使其明显。我们可以使用我们在上一步中设置的自定义属性与 hsl() 颜色函数相结合,为每个颜色函数赋予一个独特的色调:

section {
	--h: calc(var(--i) * 30);
	
	min-height: 100vh;
	background-color: hsl(var(--h, 0) 75% 50%);
}

我们将把我们的导航定位在页面的顶部,并给它一个固定的位置。

nav {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
}

虽然导航本身是固定的(以确保它在用户滚动时保持可见),但它里面的轨道将是可拖动的。这需要比视口宽,因为我们希望用户能够一直拖动它。它还需要一些填充,因为我们需要用户在我们的项目结束后能够在该区域进行拖动,这样他们就能一直移动轨道。为了确保我们的轨道在所有视口尺寸下都有一个合适的宽度,我们可以使用max()函数。这将返回两个以逗号分隔的数值中最大的一个。在狭窄的视口宽度下,我们的轨道将至少有200rem的宽度,以确保我们的项目彼此之间保持一个令人愉悦的距离。在视口宽度较大的情况下,轨道将是200%的宽度,考虑到填充,这意味着我们的项目在使用flexbox定位时将沿着视口的宽度均匀分布。

.nav__track {
	position: relative;
	min-width: max(200rem, 200%);
	padding: 1.5rem max(100rem, 100%) 0 0;
	height: 6rem;
}

.nav__list {
	/* 移除默认的列表样式 */
	list-style: none;
	margin: 0;
	padding: 0;
	
	/* 水平定位项目 */
	display: flex;
	justify-content: space-between;
}

我们还可以设置标记的样式,这将向用户显示时间轴上的当前位置。现在我们将添加一个简单的点,我们将其定位在左侧 4rem 处。如果我们还在导航项上设置了 4rem 的宽度,这应该将第一个导航项居中放置在视口左侧的标记下方。

.marker {
	position: fixed;
	top: 1.75rem;
	left: 4rem;
	width: 1rem;
	height: 1rem;
	transform: translate3d(-50%, 0, 0);
	background: blue;
	border-radius: 100%;
	z-index: 2000;
}

.nav__link {
	position: relative;
	display: block;
	min-width: 8rem;
	text-align: center;
}

你可能想像我在演示中所做的那样向轨道添加一些自定义样式,但这应该足以让我们进入下一步。

JavaScript

安装插件

我们将使用 GSAP (Greensock) 核心包及其 ScrollTrigger 和 Draggable 插件。安装 GSAP 的方法有很多种——查看此页面了解选项。如果选择 NPM 选项,则需要在 JS 文件顶部导入模块,并导入插件:

import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
import Draggable from 'gsap/Draggable'

gsap.registerPlugin(ScrollTrigger, Draggable)

创建动画时间轴

当用户滚动页面或拖动时间轴本身时,我们希望轨道水平移动。我们可以允许用户拖动标记,但如果导航项的数量超过视口水平放置的数量,这将无法正常工作。如果我们在移动轨道时保持标记静止,它会给我们更多的灵活性。

我们要做的第一件事是使用 GSAP 创建动画时间轴。我们的时间轴非常简单:它只包含一个将轨道向左移动的补间,直到最后一个项目正好位于我们之前定位的标记下方。我们需要在其他一些地方使用最后一个导航项的宽度,所以我们将创建一个函数,我们可以在需要这个值时调用。我们可以使用 GSAP 的 toArray 实用函数将导航链接的数组设置为变量:

const navLinks = gsap.utils.toArray('[data-link]')

const lastItemWidth = () => navLinks[navLinks.length - 1].offsetWidth

现在我们可以使用它来计算补间中的 x 值:

const track = document.querySelector('[data-draggable]')

const tl = gsap.timeline()
	.to(track, {
		x: () => {
			return ((track.offsetWidth * 0.5) - lastItemWidth()) * -1
		},
		ease: 'none' // important!
	})

缓和

我们还移除了我们的时间轴补间的缓动。这非常重要,因为移动将与滚动位置相关联,而缓动会破坏我们以后的计算!

创建 ScrollTrigger 实例

我们将创建一个 ScrollTrigger 实例,它将触发时间轴动画。我们将scrub值设置为0。这将使我们的动画以用户滚动的速度播放。 0 以外的值会在滚动动作和动画之间产生延迟,这在某些情况下可以很好地工作,但在这里不能很好地为我们服务。

const st = ScrollTrigger.create({
	animation: tl,
	scrub: 0
})

我们的动画时间轴将在用户从页面顶部开始滚动时开始播放,并在页面一直滚动到底部时结束。如果您需要任何不同的内容,您还需要在 ScrollTrigger 实例上指定开始和结束值。 (有关更多详细信息,请参阅 ScrollTrigger 文档)。

创建 Draggable 实例

现在我们将创建一个 Draggable 实例。我们将传入我们的轨道作为第一个参数(我们想要使其可拖动的元素)。在我们的选项(第二个参数)中,我们将为类型指定 <em>x</em>,因为我们只希望它被水平拖动。我们也可以将惯性设置为真。这是可选的,因为它需要 Inertia 插件,这是 Greensock 会员的高级插件(但可在 Codepen 上免费使用)。使用 Inertia 意味着当用户在拖动元素后放手时,它将以更自然的方式滑行到停止。这个演示不是绝对必要的,但我更喜欢这种效果。

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true
})

接下来我们要设置边界,否则元素可能会被拖出屏幕。我们将设置元素可以拖动的最小值和最大值。我们不希望它被拖到比当前起始位置更远的位置,所以我们将 minX 设置为 0。 maxX 值实际上需要与我们的时间轴补间中使用的值相同 - 那么如何关于我们为此创建一个函数:

const getDraggableWidth = () => {
	return (track.offsetWidth * 0.5) - lastItemWidth()
}

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1 // Don’t allow any dragging beyond the bounds
})

我们需要将 edgeResistance 设置为 1,这将防止任何拖动超出我们指定的范围。

把它们放在一起

现在,对于技术部分!当用户拖动元素时,我们将以编程方式滚动页面。首先要做的是在用户开始拖动轨道时禁用 ScrollTrigger 实例,并在拖动结束时重新启用它。我们可以在 Draggable 实例上使用 onDragStart 和 onDragEnd 选项来做到这一点:

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1,
	onDragStart: () => st.disable(),
	onDragEnd: () => st.enable()
})

然后我们将编写一个在拖动时调用的函数。我们将获得可拖动元素的偏移位置(使用 getBoundingClientRect())。我们还需要知道页面的总可滚动高度,即文档高度减去视口高度。让我们为此创建一个函数,以保持整洁。

const getUseableHeight = () => document.documentElement.offsetHeight - window.innerHeight

我们将使用 GSAP 的 mapRange() 实用函数来查找相对滚动位置(请参阅文档),并在 ScrollTrigger 实例上调用 scroll() 方法来更新拖动时的滚动位置:

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1,
	onDragStart: () => st.disable(),
	onDragEnd: () => st.enable(),
	onDrag: () => {
		const left = track.getBoundingClientRect().left * -1
		const width = getDraggableWidth()
		const useableHeight = getUseableHeight()
		const y = gsap.utils.mapRange(0, width, 0, useableHeight, left)
		
    st.scroll(y)
  }
})

当我们使用 Inertia 插件时,我们希望在交互的“投掷”部分调用相同的函数——在用户放开元素之后,但同时保持动量。所以让我们把它写成一个单独的函数,我们可以同时调用这两个函数:

const updatePosition = () => {
	const left = track.getBoundingClientRect().left * -1
	const width = getDraggableWidth()
	const useableHeight = getUseableHeight()
	const y = gsap.utils.mapRange(0, width, 0, useableHeight, left)

	st.scroll(y)
}

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1,
	onDragStart: () => st.disable(),
	onDragEnd: () => st.enable(),
	onDrag: updatePosition,
	onThrowUpdate: updatePosition
})

现在,当我们滚动页面或拖动轨道时,我们的滚动位置和时间轴轨道应该完全同步。

点击导航

我们还希望用户能够通过单击任何时间轴链接滚动到所需的部分。我们可以用 JS 做到这一点,但我们不一定需要:CSS 有一个允许页面内平滑滚动的属性,并且大多数现代浏览器都支持它(Safari 目前是个例外)。我们只需要这一行 CSS,我们的用户将在点击时平滑滚动到所需的部分:

html {
	scroll-behavior: smooth;
}

可访问性

考虑可能对运动敏感的用户是一种很好的做法,因此让我们包含一个 prefers-reduced-motion 媒体查询,以确保为减少运动指定系统级偏好的用户将直接跳转到相关部分:

@media (prefers-reduced-motion: no-preference) {
	html {
		scroll-behavior: smooth;
	}
}

我们的导航目前给使用键盘导航的用户带来了问题。当我们的导航溢出视口时,我们的一些导航链接会隐藏在视图之外,因为它们在屏幕外。当用户浏览链接时,我们需要将这些链接显示在视图中。我们可以将一个事件监听器附加到我们的轨道上以获取相应部分的滚动位置,并在 ScrollTrigger 实例上调用 scroll() ,这也将具有移动时间轴的效果(使它们保持同步):

track.addEventListener('keyup', (e) => {
	const id = e.target.getAttribute('href')
	
	if (!id || e.key !== 'Tab') return
	
	const section = document.querySelector(id)
	
	const y = section.getBoundingClientRect().top + window.scrollY
	
	st.scroll(y)
})

调用 scroll() 还尊重我们用户的运动偏好——具有减少运动偏好的用户将被跳转到该部分而不是平滑滚动。

练习动画最好的方式:用GSAP实现可滚动和可拖动的时间轴

动画部分

我们的时间轴现在应该工作得很好,但我们还没有任何内容。让我们为每个部分添加一个标题和图像,并在它们出现时为它们设置动画。这是一个部分的 HTML 示例,我们可以对另一个部分重复(根据需要调整内容):

<main>
	<section id="section_1" style="--i: 0">
		<div class="container">
			<h2 class="section__heading">
				<span>2000</span>
				<span>Pablo Honey</span>
			</h2>
			<div class="section__image">
				<img src="https://pic1.zhimg.com/80/v2-6f067bf071e5743265da744d5cc64a1c_1440w.jpg" width="1200" height="1200" />
			</div>
		</div>
	</section>
	<!--more sections-->
</main>

我正在使用 display: grid 以令人愉悦的排列方式定位标题和图像 - 但可以随意放置它们。我们将只专注于这部分的 JS。

使用 GSAP 创建时间表

将创建一个名为 initSectionAnimation() 的函数。如果我们的用户喜欢减少运动,我们要做的第一件事就是尽早返回。我们可以使用 matchMedia 方法使用偏好减少运动媒体查询:

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')

const initSectionAnimation = () => {
	
	if (prefersReducedMotion.matches) return
}

initSectionAnimation()

接下来,我们将为每个部分设置动画开始状态:

const initSectionAnimation = () => {
	if (prefersReducedMotion.matches) return
	
	sections.forEach((section, index) => {
		const heading = section.querySelector('h2')
		const image = section.querySelector('.section__image')
		
		gsap.set(heading, {
			opacity: 0,
			y: 50
		})
		gsap.set(image, {
			opacity: 0,
			rotateY: 15
		})
	}
}

然后我们将为每个部分创建一个新的时间轴,将 ScrollTrigger 添加到时间轴本身以控制何时播放动画。这次我们可以直接执行此操作,而不是创建单独的 ScrollTrigger 实例,因为我们不需要将此时间轴连接到可拖动元素。 (这段代码都在 forEach 循环中。)我们将在时间轴上添加一些补间,以将标题和图像动画化到视图中。


const sectionTl = gsap.timeline({
	scrollTrigger: {
		trigger: section,
		start: () => 'top center',
		end: () => `+=${window.innerHeight}`,
		toggleActions: 'play reverse play reverse'
	}
})

sectionTl.to(image, {
	opacity: 1,
	rotateY: -5,
	duration: 6,
	ease: 'elastic'
})
.to(heading, {
	opacity: 1,
	y: 0,
	duration: 2
}, 0.5) 

默认情况下,我们的补间将一个接一个地播放。但是我使用 position 参数来指定补间动画应该从时间轴的开始播放 0.5 秒,所以我们的动画重叠。

这是完整的演示:

练习动画最好的方式:用GSAP实现可滚动和可拖动的时间轴