实现 height: auto 的高度过渡动画
对于一个 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
:
- Resize Observer API - Web API 接口参考 | MDN (mozilla.org)
- Element.getBoundingClientRect() - Web API 接口参考 | MDN (mozilla.org)
- Window:requestAnimationFrame() 方法 - Web API 接口参考 | MDN (mozilla.org)
基本过渡效果实现
使用以上 API
,就可以初步实现一个监听元素尺寸变化,并对其应用 FLIP
动画的函数 useBoxTransition
,代码如下:
/**
*
* @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
函数如下:
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(() => {
// 为避免使用了函数节流后,导致回调函数延迟触发使得 cancelAnimationFrame 失败,因此使用 isTransforming 变量控制回调函数中的操作是否执行
if (isTransforming) {
const boxSize = getBoxSize()
updateBoxsize(boxSize)
frameId = requestAnimationFrame(animationFrameCallback)
}
}, 20)
// 过渡事件的回调函数,在过渡过程中实时更新 boxSize
function onTransitionStart(e: Event) {
if (e.target !== el) return
// 变形中断的一瞬间,boxSize 的尺寸可能是非预期的,因此在变形开始时,将最新的 3 个可能是非预期的 boxSize 移除
if (boxSizeList.length > 1) {
boxSizeList = boxSizeList.slice(0, 1)
// console.log('移除3项', 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
const boxSize = { width, height }
// 当 box 尺寸发生变化时以及初次触发回调时,将此刻 box 的目标尺寸暴露给 keyBoxSizeRef
keyBoxSizeRef.value = boxSize
// box 首次被观察时会触发一次回调,此时不需要应用过渡,只需将当前尺寸记录到 boxSizeList 中
if (!isObserved) {
isObserved = true
boxSizeList.push(boxSize)
return
}
// 当 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
组件代码如下:
<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)
,即可恢复正常的高度计算。
至于为什么这样会造成高度计算错误,希望有大神能解惑。
来源:juejin.cn/post/7307894647655759911