实现 height: auto 的高度过渡动画

9,147 阅读5分钟

对于一个 height 设置为 auto 的元素,当它的高度发生了不由样式引起的改变时,并不会触发 transition 过渡动画。

容器元素的高度往往是由其内容决定的,如果一个容器元素的内容高度突然发生了改变,而无法进行过渡动画,有时会显得比较生硬,比如下面的登录框组件:

001.gif

那么这种非样式引起的变化如何实现过渡效果呢?可以借助 FLIP 技术。

FLIP 是什么

FLIPFirstLastInvertPlay 的缩写,其含义是:

  • First - 获取元素变化之前的状态
  • Last - 获取元素变化后的最终状态
  • Invert - 将元素从 Last 状态反转到 First 状态,比如通过添加 transform 属性,使得元素变化后,看起来仍像是处于 First 状态一样
  • Play - 此时添加过渡动画,再移除 Invert 效果(取消 transform),动画就会开始生效,使得元素看起来从 First 过渡到了 Last

需要用到的 Web API

要实现一个基本的 FLIP 过渡动画,需要使用到以下一些 Web API

基本过渡效果实现

使用以上 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
}

效果如下所示:

002.gif

效果改进

目前已经实现了初步的过渡效果,但在一些场景下会有些瑕疵:

  • 如果在过渡动画完成前,元素有了新的状态变化,则动画被打断,无法平滑过渡到新的状态
  • FLIP 动画过渡过程中,实际上发生变化的是 transform 属性,并不影响元素在文档流中占据的位置,如果需要该元素影响周围的元素,那么周围元素无法实现平滑过渡

如下所示:

003.gif

对于动画打断问题的优化思路

  • 使用 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 是暴露出去的 box 的实时目标尺寸
  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)
    }
    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 大小
      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>

优化后的效果如下:

004.gif

005.gif

注意点

过渡元素本身的 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.offsetHeightResizeObserverEntry 对象。

获取元素高度时遇到的 bug

测试案例中使用了 elementPlus UI 库的 el-tabs 组件,当元素包含该组件时,无论是使用 Element.getBoundingClientRect()Element.offsetHeight 还是使用 Element.StylegetComputedStyle(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),即可恢复正常的高度计算。

至于为什么这样会造成高度计算错误,希望有大神能解惑。