"动画" 是前端开发中老生常谈的话题了;
无论是原生还是框架,实现动画效果的插件有很多种,比如说:
Animate.css:CSS动画库,可以很容易地通过类名来添加预定义的动画效果。
GSAP:非常强大且灵活的动画库,可以创建复杂和高性能的动画,支持时间线功能,可以精细控制动画序列。
jQuery.animate():jQuery的内置方法,可以通过编写简单的代码来创建动画效果,比CSS动画具有更好的兼容性,但性能上不如专门的动画库。
Velocity.js:快速的JavaScript动画引擎,与jQuery的.animate()方法有着类似的API,但提供了更高的性能。
and so on......
以上说了那么多实现动画的工具,那么这些工具与今天主角View Transition API有什么区别呢?接下来让我们来感受一下。
什么是View Transition API (vta) ?
下面我们来看几个例子:
这种动画在 Native 端我们看的比较多,在 Web 端可以实现,但略麻烦,尤其是遇到路由切换 View 渲染的都是两个完全不一样的组件(页面内 DOM 全变了) 的情况下。
View Transitions API提供了一种机制,可轻松创建不同网站视图之间的动画过渡。这包括在单页应用 (SPA) 中为 DOM 状态之间添加动画,以及在多页应用 (MPA) 中为文档之间的导航添加动画。
vta 设计上是为了处理视图(或路由)间的转换效果。
- 方法与属性 startViewTransition() updateCallbackDone ready finished skipTransition()
- css规则与伪元素 view-transition-name ::view-transition ::view-transition-group() ::view-transition-image-pair() ::view-transition-old() 接下来让我用一个简单的demo来深入了解下 View Transition API
Background Transition
我们先来做一个点击按钮背景切换的效果:
Html:
html
<button id="btn">切换</button>
Css:
css
:root {
--bg-color: #fff;
background-color: var(--bg-color);
}
:root.dark {
--bg-color: #000;
}
Js:
javascript
btn.addEventListener('click',(e)=>{
const transition = document.startViewTransition(()=>{
document.documentElement.classList.toggle('dark')
)}
)}
开启动画 document.startViewTransition(updateCallback) ,调用startViewTransition后,我们会得到一个ViewTransition的实例对象 transition; 实例属性: 分别代表过渡过程的三个状态节点,各自均返回一个Promise对象 · updateCallbackDone:回调函数执行完毕 · ready:准备就绪,即将开始播放动效 · finished:动效播放完毕 实例方法: skipTransition() : 跳过视图过渡的动画部分,但不会跳过运行document.startViewTransition()的回调,该回调会更新 DOM;
可以看到,背景颜色的改变有了淡入淡出的效果。
视图过渡过程
让我们来看看这是如何工作的:
-
当调用 document.startViewTransition() 时,API 会截取一「帧」。
-
执行传入 startViewTransition(callback) 的回调函数,并等待界面响应更新。
-
API 会捕获页面的新状态并实时展示,更新后,再截取一「帧」。
-
api 构造了一个伪元素树
html
::view-transition
└─ ::view-transition-group(root)
└─ ::view-transition-image-pair(root)
├─ ::view-transition-old(root)
└─ ::view-transition-new(root)
// 只用去控制这个伪元素树,即可实现过渡动画效果。
此时过渡动画即将运行,ViewTransition.ready Promise兑现,你可以响应它进行一些操作,例如运行自定义的 JavaScript 动画,而不是默认的动画
-
旧页面视图的opacity从 1 过渡到 0,而新视图从 0 过渡到 1,这就是默认的交叉淡入淡出效果。
-
当过渡动画结束时,ViewTransition.finished Promise 兑现,你可以响应它进行一些操作。
自定义动画
需要注意的是 vta 的默认动画是:cross-fade ,那么如何取消默认动画呢?
css
::view-transition-old(root),
::view-transition-new(root) {
animation: none; //取消默认动画
}
还是用以上背景切换的demo为例,我们来完成一个背景以当前点击的位置开始的圆形过渡动画
javascript
btn.addEventListener('click', (e) => {
const transition = document.startViewTransition(() => {
document.documentElement.classList.toggle('dark')
})
const x = e.clientX
const y = e.clientY
// 从点击点到窗口最远边缘的距离,这个距离即为圆的半径,用于确定一个圆形裁剪路径
const radius = Math.sqrt(Math.max(x, (window.innerWidth - x)) ** 2 + Math.max(y, (window.innerHeight - y)) ** 2)
// transition.ready:一个在伪元素树创建且过渡动画即将开始时兑现的 Promise
transition.ready.then(() => {
// 实现过渡的过程 circle
document.documentElement.animate(
{
clipPath: [
`circle(0 at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`,
]
},
{
duration: 300,
pseudoElement: '::view-transition-new(root)',
}
)
})
})
效果如下:
为不同元素应用不同动画
上面的例子中,我们总是在思考vta的工作流程以及自定义动画,但是我们也发现了,整个视图(:root)都在参与动画,但是在我们的平时的工作中这样是不合理的,我们来看看如何给不同的元素应用不同的动画
可以给按钮添加一个父容器 .container
css
.container {
view-transition-name: container;
}
::view-transition-old(container),
::view-transition-new(container) {
animation: none;
}
此时的伪元素树则会变成
css
::view-transition
├─ ::view-transition-group(root) // 默认图层
│ └─ ::view-transition-image-pair(root)
│ ├─ ::view-transition-old(root)
│ └─ ::view-transition-new(root)
└─ ::view-transition-group(container)。// container图层
└─ ::view-transition-image-pair(container)
├─ ::view-transition-old(container)
└─ ::view-transition-new(container)
跨文档(路由)的视图转换(MPA)
通过刚才的案例,我们大致了解了View Transition API 的应用与工作流程; 接下来我们来看下今天的重点,「 跨文档(路由)的视图转换 」,如何利用 View Transition API 在web页面之间实现 Native般丝滑的过渡!
先看demo:View-Transition
涉及的知识点
View Transition API
@view-transition
在跨文档导航的情况下,CSS at -rule用于选择当前文档和目标文档进行视图转换。@view-transition为了实现跨文档视图转换,导航的当前文档和目标文档也需要位于同一来源。
less
@view-transition {
navigation: auto;
}
// auto:文档在参与导航时将经历视图转换,前提是导航是同源的
// none:该文档将不会经历视图转换。
HTML DOM API
PageSwapEvent: 当您跨文档导航时,如果上一个文档即将卸载,则会触发此pageswap事件 PageRevealEvent:在跨文档导航期间,如果导航触发了视图转换,它允许您操作从正在导航到的文档的相关视图转换(提供对相关对象的访问) 实例属性: viewTransition:包含一个ViewTransition代表跨文档导航的活动视图转换的对象
实战分析
首先是css部分,css比较随意,具体布局根据大家心情而定,但是最重要的有一点
css
// 开启文档视图转换
@view-transition {
navigation: auto;
}
首页 index.html
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>index</title>
<link rel="stylesheet" href="../shared/styles.css">
<script src="../mpa/scripts.js"></script>
</head>
<body id="overview">
<main>
<ul class="profiles">
<li id="wukong">
<a href="../mpa/wukong.html">
<img src="../shared/img/wukong.png" alt="">
<span>悟空</span>
</a>
</li>
<li id="lufei">
<a href="../mpa/lufei.html">
<img src="../shared/img/lufei.png" alt="">
<span>路飞</span>
</a>
</li>
</ul>
</main>
</body>
</html>
子页 以 wukong.html为主
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>悟空</title>
<link rel="stylesheet" href="../shared/styles.css">
<script src="../mpa/scripts.js"></script>
</head>
<body id="detail">
<main>
<h1>悟空</h1>
<img src="../shared/img/wukong.png" alt="">
<p>Hello</p>
<a href="../mpa/" class="back">← Back!</a>
</main>
</body>
</html>
下面是JS部分
思路其实非常简单
稍微观察下就能发现,无论是首页->详情页,还是详情页->首页 发生动画的永远都是头像(avatar)、名称(name), 当然也可以有其他,这个具体哪些需要动画全凭自己喜好;
比如,首页->详情页 当我们将要从首页离开的时候,对首页的avatar以及name等Dom设置viewTransitionName; 当我们即将进入详情页的时候,对详情页的avatar以及name等Dom设置与首页对应元素相同的viewTransitionName,对两个页面的元素进行连接,这样是不是就会有一个自然的过渡效果
开始之前我们先来做一些准备工作
javascript
// 定义个人资料页面的基础路径,用于页面URL匹配
const basePath = "/view_transition/profiles/mpa";
// 使用输入的URL与主页面的URL模式进行匹配===>确认是否为主页
const homePagePattern = new URLPattern(`${basePath}(/)*`, window.origin);
const isHomePage = (url) => {
return homePagePattern.exec(url);
};
// 使用输入的URL与详情页面的URL模式进行匹配===>确认是否为详情页
const profilePagePattern = new URLPattern(
`${basePath}/:profile`,
window.origin
);
const isProfilePage = (url) => {
return profilePagePattern.exec(url);
};
// 从详情页URL中提取个人资料名字
const extractProfileNameFromUrl = (url) => {
const match = profilePagePattern.exec(url);
return match?.pathname.groups.profile;
};
到这里好像还少了些东西
就像刚才说的,想让两个页面出现元素过渡效果,首先需要元素之间有一个相同的viewTransitionName来建立连接,方便api去获取新旧页面的屏幕截图,但是为了解决资源冲突的问题,我们需要用完viewTransitionName后及时清除
开搞开搞!
我们先写一个用来管理viewTransitionName的函数
javascript
const setTemporaryViewTransitionNames = async (entries, vtPromise) => {
for (const [$el, name] of entries) {
$el.style.viewTransitionName = name;
}
await vtPromise;
for (const [$el, name] of entries) {
$el.style.viewTransitionName = "";
}
};
接下来使用 HTML DOM API 中的PageSwapEvent、PageRevealEvent
PageSwapEvent
当跨文档导航时,如果上一个文档即将卸载,则会触发此pageswap事件 在跨文档导航期间,如果导航触发了视图转换,则事件对象允许从正在导航的文档PageSwapEvent操纵相关视图转换(提供对相关对象的访问)。它还提供对有关导航类型以及当前文档和目标文档的信息的访问。ViewTransition
注意:对于pageswap事件来说,viewTransitionName的清除没有那么重要,因为页面即将离开,返回时必重新渲染,所以是否清除viewTransitionName无所谓了
javascript
window.addEventListener("pageswap", async (e) => {
if (e.viewTransition) {
const currentUrl = e.activation.from?.url ? new URL(e.activation.from.url) : null;
const targetUrl = new URL(e.activation.entry.url);
//仅转换到相同的basePath,不满足则跳过动画
if (!targetUrl.pathname.startsWith(basePath)) {
e.viewTransition.skipTransition();
}
//从主页转到详情页
if (isProfilePage(targetUrl)) {
const profile = extractProfileNameFromUrl(targetUrl).split(".")[0];
setTemporaryViewTransitionNames(
[
[document.querySelector(`#${profile} span`), "name"],
[document.querySelector(`#${profile} img`), "avatar"],
],
e.viewTransition.ready
);
}
//从详情页面转到主页
if (isProfilePage(currentUrl) && isHomePage(targetUrl)) {
setTemporaryViewTransitionNames(
[
[document.querySelector(`#detail main h1`), "name"],
[document.querySelector(`#detail main img`), "avatar"],
],
e.viewTransition.ready
);
}
}
});
PageRevealEvent
首次呈现文档时会触发此pagereveal事件,无论是从网络加载新文档还是激活文档
注意:对于当前案例来说pagereveal事件必须要清除viewTransitionName,因为文档呈现与文档离开的操作会在同一页面发生,如果viewTransitionName存在冲突则会影响屏幕对当前页面状态的截图,导致冲突;
另外,可能会有同学思考,既然pagereveal需要清除viewTransitionName,那么应该在ready还是在finished后完成?其实都可以,因为无论ready还是finished,当添加viewTransitionName的同时,api已经获得了新、旧页面的截图,无论ready还是finished都不会影响过渡动画的进行。
javascript
window.addEventListener("pagereveal", async (e) => {
if (!navigation.activation.from) return;
if (e.viewTransition) {
const fromUrl = new URL(navigation.activation.from.url);
const currentUrl = new URL(navigation.activation.entry.url);
//仅转换到相同的basePath
//>>跳过!
if (!fromUrl.pathname.startsWith(basePath)) {
e.viewTransition.skipTransition();
}
//从个人资料页面转到主页
if (isProfilePage(fromUrl) && isHomePage(currentUrl)) {
const profile = extractProfileNameFromUrl(fromUrl).split(".")[0];
setTemporaryViewTransitionNames(
[
[document.querySelector(`#${profile} span`), "name"],
[document.querySelector(`#${profile} img`), "avatar"],
],
e.viewTransition.finished
);
}
//转到个人资料页
if (isProfilePage(currentUrl)) {
setTemporaryViewTransitionNames(
[
[document.querySelector(`#detail main h1`), "name"],
[document.querySelector(`#detail main img`), "avatar"],
],
e.viewTransition.finished
);
}
}
});
到这里 一个完整的 MPA下跨文档之间的过渡动画 就全部完成了! 完整的demo:github.com/iamlvvvvvv/...
兼容性
View Transitions API
PageSwapEvent、PageRevealEvent
@view-transition