对于一个 height
设置为 auto
的元素,当它的高度发生了不由样式引起的改变时,并不会触发 transition
过渡动画。
容器元素的高度往往是由其内容决定的,如果一个容器元素的内容高度突然发生了改变,而无法进行过渡动画,有时会显得比较生硬,比如下面的登录框组件:
那么这种非样式引起的变化如何实现过渡效果呢?可以借助 FLIP
技术。
FLIP
是什么
FLIP
是 First
,Last
,Invert
,Play
的缩写,其含义是:
First
- 获取元素变化之前的状态Last
- 获取元素变化后的最终状态Invert
- 将元素从Last
状态反转到First
状态,比如通过添加transform
属性,使得元素变化后,看起来仍像是处于First
状态一样Play
- 此时添加过渡动画,再移除Invert
效果(取消transform
),动画就会开始生效,使得元素看起来从First
过渡到了Last
需要用到的 Web API
要实现一个基本的 FLIP
过渡动画,需要使用到以下一些 Web API
-
ResizeObserver API
Resize Observer API
能够观察和响应Element
内容盒或边框盒,或者SVGElement
边框尺寸的变化,并以高效的方式做出响应ResizeObserver
构造函数- 用于创建一个新的
ResizeObserver
实例对象 - 接收一个
callback
参数- 每当观测的元素调整大小时,该函数会被调用
- 该函数接收两个参数
entries
- 一个
ResizeObserverEntry
对象数组 ResizeObserverEntry
对象可以用于获取每个元素改变后的新尺寸
- 一个
observer
- 对
ResizeObserver
实例对象的引用
- 对
- 用于创建一个新的
ResizeObserver
实例对象的方法observe(target[, options])
- 用于监听指定的
Element
或SVGElement
- 接收参数
target
- 对要监听的
Element
或SVGElement
的引用
- 对要监听的
options
(可选)- 一个参数对象,允许为监听的对象设置参数
- 目前只有
box
一个字段,用于在监听Element
时指定observer
将监听的盒模型,可选值有:'content-box'
(默认值)CSS
中定义的内容区域的大小
'border-box'
CSS
中定义的边框区域的大小
'device-pixel-content-box'
- 在对元素或其祖先引用任何
CSS
转换之前,CSS
中定义的内容区域的大小,以设备像素为单位
- 在对元素或其祖先引用任何
- 用于监听指定的
unobverse(target)
- 结束对指定的
Element
或SVGElement
的监听 - 接收
target
参数,即对要取消监听的Element
或SVGElement
的引用
- 结束对指定的
disconnect()
- 取消对所有的
Element
或SVGElement
的监听
- 取消对所有的
ResizeObserverEntry
对象ResizeObserverEntry
对象就是传递给ResizeObserver
构造函数中的回调函数参数的数组中的对象,可以用来获取正在观察的Element
或SVGElement
最新的大小- 属性
borderBoxSize
- 值为一个数组,包含被监听元素的边框盒大小的对象
borderBoxSize
和contentBoxSize
为什么是数组- 因为
CSS
规范允许元素具有多个盒模型 - 在实际应用中,大多数元素只有一个盒模型,因此
borderBoxSize
和contentBoxSize
数组通常只包含一个元素 - 为了完全兼容可能出现的多盒模型情况,
borderBoxSize
和contentBoxSize
被设计为一个数组,使得能够处理复杂布局场景中元素的尺寸变化
- 因为
- 数组中对象的属性
blockSize
- 被监听的元素在块方向上的长度
- 对于具有水平
writing-mode
的盒子,这是垂直尺寸或者高度 - 如果
writing-mode
是垂直的,这是水平的尺寸或者宽度
inlineSize
- 被监听的元素在内联方向上的长度
- 对于具有水平
writing-mode
的盒子,这是水平尺寸或者宽度 - 如果
writing-mode
是垂直的,这是垂直的尺寸或者高度
contentBoxSize
- 值为一个数组,包含被监听元素的内容盒大小的对象
- 数组中对象的属性于
borderBoxSize
中的相同
contentRect
- 值为一个包含被监听元素大小的
DOMRectReadOnly
对象,包含属性width
和height
- 如果
target
是一个HTML Element
,则contentRect
值是元素的内容盒 - 如果
target
是一个SVGElement
,则contentRect
是SVG
的边界框
- 如果
- 该属性是在
Resize Observer API
早期实现遗留下来的,比borderBoxSize
和cotentBoxSize
有着更好的兼容性,在未来的版本中可能被弃用
- 值为一个包含被监听元素大小的
devicePixelContentBoxSize
- 值为一个数组,包含被监听元素的设备像素大小
- 数组中对象的属性于
borderBoxSize
中的相同
target
- 值为正在被监听的
Element
或SVGElement
的引用
- 值为正在被监听的
-
Element.getBoundingClientRect()
- 返回一个
DOMRect
对象,其提供了元素的大小及其相对于视口的位置 - 可以从
DOMRect
获取到的属性x
y
width
height
top
right
bottom
left
- 返回一个
-
requestAnimationFrame(callback)
- 浏览器提供的
requestAnimationFrame
方法被设计用来创建高效率的JavaScript
动画 - 接收一个回调函数作为参数
- 这个回调函数会在屏幕下一次刷新(比如
60Hz
刷新率的屏幕,一秒就会执行60
次该回调函数)之前被执行 - 浏览器会传递
DOMHighResTimeStamp
作为参数给该回调函数DOMHighResTimeStamp
是一个由High Resolution Time API
提供的数据类型,表示一个以毫秒为单位的时间值,其精度可以达到微秒级(1ms
的百万分之一)- 在
requestAnimationFrame
的上下文中,传递给回调函数的DOMHighResTimeStamp
参数表示的是从文档开始加载到执行该回调函数时的经过时间
- 执行后会返回一个请求
ID
- 它是一个非零整数,是当前动画帧请求的唯一标识
- 可以传递此
ID
给cancelAnimationFrame
方法以取消动画帧请求
- 浏览器提供的
基本过渡效果实现
使用以上 API
,就可以初步实现一个监听元素尺寸变化,并对其应用 FLIP
动画的函数 useBoxTransition
,代码如下:
typescript
/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @returns 返回一个函数,调用后取消对过渡元素尺寸变化的监听
*/
export default function useBoxTransition(el: HTMLElement, duration: number) {
// boxSize 用于记录元素处于 First 状态时的尺寸大小
let boxSize: {
width: number
height: number
} | null = null
const elStyle = el.style // el 的 CSSStyleDeclaration 对象
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作
// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways - rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize
// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
// box 首次被观察时会触发一次回调,此时 boxSize 为 null,scale 应为 1
const scaleX = boxSize ? boxSize.width / width : 1
const scaleY = boxSize ? boxSize.height / height : 1
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
elStyle.setProperty('transform', `scale(${scaleX}, ${scaleY})`)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', 'none')
elStyle.setProperty('transition', `transform ${duration}ms`)
})
// 记录变化后的 boxSize
boxSize = { width, height }
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
}
return cancelBoxTransition
}
效果如下所示:
效果改进
目前已经实现了初步的过渡效果,但在一些场景下会有些瑕疵:
- 如果在过渡动画完成前,元素有了新的状态变化,则动画被打断,无法平滑过渡到新的状态
FLIP
动画过渡过程中,实际上发生变化的是transform
属性,并不影响元素在文档流中占据的位置,如果需要该元素影响周围的元素,那么周围元素无法实现平滑过渡
如下所示:
对于动画打断问题的优化思路
- 使用
Window.requestAnimationFrame()
方法在每一帧中获取元素的尺寸 - 这样做可以实时地获取到元素的尺寸,实时地更新
First
状态
对于元素在文档流中问题的优化思路
- 应用过渡的元素外可以套一个
.outer
元素,其定位为relative
,过渡元素的定位为absolute
,且居中于.outer
元素 - 当过渡元素尺寸发生变化时,通过
resizeObserver
获取其最终的尺寸,将其宽高设置给.outer
元素(实例代码运行于Vue 3
中,因此使用的是Vue
提供的ref api
将其宽高暴露出来,可以方便地监听其变化;如果在React
中则可以将设置.outer
元素宽高的方法作为参数传入useBoxTransition
中,在需要的时候调用),并给.outer
元素设置宽高的过渡效果,使其在文档流中所占的位置与过渡元素的尺寸同步 - 但是也要注意,这样做可能会引起浏览器高频率的重排,在复杂布局中慎用!
改进后的useBoxTransition
函数如下:
typescript
import throttle from 'lodash/throttle'
import { ref } from 'vue'
type BoxSize = {
width: number
height: number
}
type BoxSizeRef = globalThis.Ref<BoxSize>
/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @param {string} mode 过渡动画缓动速率,同 CSS transition-timing-function 可选值
* @returns 返回一个有两个项的元组:第一项为 keyBoxSizeRef,当元素大小发生变化时会将变化后的目标尺寸发送给 keyBoxSizeRef.value;第二项为一个函数,调用后取消对过渡元素尺寸变化的监听
*/
export default function useBoxTransition(
el: HTMLElement,
duration: number,
mode?: string
) {
let boxSizeList: BoxSize[] = [] // boxSizeList 表示对 box 的尺寸的记录数组;为什么是使用列表:因为当 box 尺寸变化的一瞬间,box 的 transform 效果无法及时移除,此时 box 的尺寸可能是非预期的,因此使用列表来记录 box 的尺寸,在必要的时候尽可能地将非预期的尺寸移除
const keyBoxSizeRef: BoxSizeRef = ref({ width: 0, height: 0 }) // keyBoxSizeRef 是
let isObserved = false // box 是否已经开始被观察
let frameId = 0 // 当前 animationFrame 的 id
let isTransforming = false // 当前是否处于变形过渡中
const elStyle = el.style // el 的 CSSStyleDeclaration 对象
const elComputedStyle = getComputedStyle(el) // el 的只读动态 CSSStyleDeclaration 对象
// 获取当前 boxSize 的函数
function getBoxSize() {
const rect = el.getBoundingClientRect() // el 的 DOMRect 对象
return { width: rect.width, height: rect.height }
}
// 同步更新 boxSizeList
function updateBoxsize(boxSize: BoxSize) {
boxSizeList.push(boxSize)
// 只保留前最新的 4 条记录
boxSizeList = boxSizeList.slice(-4)
}
// 定义 animationFrame 的回调函数,使得当 box 变形时可以更新 boxSize 记录
const animationFrameCallback = throttle(() => {
// 为避免使用了函数节流后,导致回调函数延迟触发而产生的最终 boxSizeRef 中的尺寸信息不准确,因此使用 isTransforming 变量控制回调函数中的操作是否执行
if (isTransforming) {
const boxSize = getBoxSize()
updateBoxsize(boxSize)
frameId = requestAnimationFrame(animationFrameCallback)
}
}, 20)
// 过渡事件的回调函数,在过渡过程中实时更新 boxSize
function onTransitionStart(e: Event) {
if (e.target !== el) return
// 变形中断的一瞬间,boxSize 的尺寸可能是非预期的,因此在变形开始时,将最新的几个可能是非预期的 boxSize 移除,只留下最早的那个
if (boxSizeList.length > 1) {
boxSizeList = boxSizeList.slice(0, 1)
}
isTransforming = true
frameId = requestAnimationFrame(animationFrameCallback)
// console.log('过渡开始')
}
function onTransitionCancel(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡中断')
}
function onTransitionEnd(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡完成')
}
el.addEventListener('transitionstart', onTransitionStart)
el.addEventListener('transitioncancel', onTransitionCancel)
el.addEventListener('transitionend', onTransitionEnd)
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作
// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways - rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize
// box 首次被观察时会触发一次回调,此时不需要应用过渡,只需将当前尺寸记录到 boxSizeList 中
if (!isObserved) {
isObserved = true
const boxSize = { width, height }
boxSizeList.push(boxSize)
keyBoxSizeRef.value = boxSize
return
}
// 当 box 尺寸发生变化时,将此刻 box 的目标尺寸暴露给 keyBoxSizeRef
keyBoxSizeRef.value = {
width,
height
}
// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
// 不读取序号为 0 的记录,以免尺寸变化的一瞬间,box 的 transform 未来得及移除,使得最新的一条尺寸记录是非预期的
const scaleX = boxSizeList[0].width / width
const scaleY = boxSizeList[0].height / height
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
const originalTransform =
elStyle.transform || elComputedStyle.getPropertyValue('--transform')
elStyle.setProperty(
'transform',
`${originalTransform} scale(${scaleX}, ${scaleY})`
)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', originalTransform)
elStyle.setProperty('transition', `transform ${duration}ms ${mode}`)
})
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
cancelAnimationFrame(frameId)
}
const result: [BoxSizeRef, () => void] = [keyBoxSizeRef, cancelBoxTransition]
return result
}
相应的 vue
组件代码如下:
vue
<template>
<div class="outer" ref="outerRef">
<div class="card-container" ref="cardRef">
<div class="card-content">
<slot></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import useBoxTransition from '@/utils/useBoxTransition'
type Props = {
transition?: boolean
duration?: number
mode?: string
}
const props = defineProps<Props>()
const { transition, duration = 200, mode = 'ease' } = props
const cardRef = ref<HTMLElement | null>(null)
const outerRef = ref<HTMLElement | null>(null)
let cancelBoxTransition = () => {} // 取消 boxTransition 效果
onMounted(() => {
if (cardRef.value) {
const cardEl = cardRef.value as HTMLElement
const outerEl = outerRef.value as HTMLElement
if (transition) {
const boxTransition = useBoxTransition(cardEl, duration, mode)
const keyBoxSizeRef = boxTransition[0]
cancelBoxTransition = boxTransition[1]
outerEl.style.setProperty(
'--transition',
`weight ${duration}ms ${mode}, height ${duration}ms ${mode}`
)
watch(keyBoxSizeRef, () => {
outerEl.style.setProperty('--height', keyBoxSizeRef.value.height + 'px')
outerEl.style.setProperty('--width', keyBoxSizeRef.value.width + 'px')
})
}
}
})
onUnmounted(() => {
cancelBoxTransition()
})
</script>
<style scoped lang="less">
.outer {
position: relative;
&::before {
content: '';
display: block;
width: var(--width);
height: var(--height);
transition: var(--transition);
}
.card-container {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
--transform: translate(-50%, -50%);
transform: var(--transform);
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.7);
border-radius: var(--border-radius, 20px);
overflow: hidden;
backdrop-filter: blur(10px);
padding: 30px;
box-shadow: var(--box-shadow, 0 0 15px 0 rgba(0, 0, 0, 0.3));
}
}
</style>
优化后的效果如下:
注意点
过渡元素本身的 transform
样式属性
useBoxTransition
函数中会覆盖应用过渡的元素的 transform
属性,如果需要额外为元素设置其它的 transform
效果,需要使用 css
变量 --transform
设置,或使用内联样式设置。
这是因为,useBoxTransition
函数中对另外设置的 transform
效果和过渡所需的 transform
效果做了合并。
然而通过 getComputedStyle(Element)
读取到的 transform
的属性值总是会被转化为 matrix()
的形式,使得 transform
属性值无法正常合并;而 CSS
变量和使用 Element.style
获取到的内联样式中 transform
的值是原始的,可以正常合并。
如何选择获取元素宽高的方式
Element.getBoundingClientRect()
获取到的 DOMRect
的宽高包含了 transform
变化,而 Element.offsetWidth
/ Element.offsetHeight
以及 ResizeObserverEntry
对象获取到的宽高是元素本身的占位大小。
因此在需要获取 transition
过程中,包含 transform
效果的元素大小时,使用 Element.getBoundingClientRect()
,否则可以使用 Element.offsetWidth
/ Element.offsetHeight
或 ResizeObserverEntry
对象。
获取元素高度时遇到的 bug
测试案例中使用了 elementPlus
UI
库的 el-tabs
组件,当元素包含该组件时,无论是使用 Element.getBoundingClientRect()
、Element.offsetHeight
还是使用 Element.Style
、getComputedStyle(Element)
获取到的元素高度均缺少了 40px
;而使用 ResizeObserverEntry
对象获取到的高度则是正确的,但是它无法脱离 ResizeObserver API
独立使用。
经过测试验证,缺少的 40px
高度来自于 el-tabs
组件中 .el-tabs__header
元素的高度,也就是说,在获取元素高度时,将 .el-tabs__header
元素的高度忽略了。
测试后找出的解决方法是,手动将 .el-tabs__header
元素样式(注意不要写在带 scoped
属性的 style
标签中,会被判定为布局样式而无法生效)的 height
属性指定为 calc(var(--el-tabs-header-height) - 1px)
,即可恢复正常的高度计算。
至于为什么这样会造成高度计算错误,希望有大神能解惑。