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

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

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

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

FLIP 是什么

FLIPFirstLastInvertPlay 的缩写,其含义是:

  • 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])
        • 用于监听指定的 ElementSVGElement
        • 接收参数
          • target
            • 对要监听的 ElementSVGElement 的引用
          • options(可选)
            • 一个参数对象,允许为监听的对象设置参数
            • 目前只有 box 一个字段,用于在监听 Element 时指定 observer 将监听的盒模型,可选值有:
              • 'content-box'(默认值)
                • CSS 中定义的内容区域的大小
              • 'border-box'
                • CSS 中定义的边框区域的大小
              • 'device-pixel-content-box'
                • 在对元素或其祖先引用任何 CSS 转换之前,CSS 中定义的内容区域的大小,以设备像素为单位
      • unobverse(target)
        • 结束对指定的 ElementSVGElement 的监听
        • 接收 target 参数,即对要取消监听的 ElementSVGElement 的引用
      • disconnect()
        • 取消对所有的 ElementSVGElement 的监听
    • ResizeObserverEntry 对象
      • ResizeObserverEntry 对象就是传递给 ResizeObserver 构造函数中的回调函数参数的数组中的对象,可以用来获取正在观察的 ElementSVGElement 最新的大小
      • 属性
        • borderBoxSize
          • 值为一个数组,包含被监听元素的边框盒大小的对象
          • borderBoxSizecontentBoxSize 为什么是数组
            • 因为 CSS 规范允许元素具有多个盒模型
            • 在实际应用中,大多数元素只有一个盒模型,因此 borderBoxSizecontentBoxSize 数组通常只包含一个元素
            • 为了完全兼容可能出现的多盒模型情况,borderBoxSizecontentBoxSize 被设计为一个数组,使得能够处理复杂布局场景中元素的尺寸变化
          • 数组中对象的属性
            • blockSize
              • 被监听的元素在块方向上的长度
              • 对于具有水平 writing-mode 的盒子,这是垂直尺寸或者高度
              • 如果 writing-mode 是垂直的,这是水平的尺寸或者宽度
            • inlineSize
              • 被监听的元素在内联方向上的长度
              • 对于具有水平 writing-mode 的盒子,这是水平尺寸或者宽度
              • 如果 writing-mode 是垂直的,这是垂直的尺寸或者高度
        • contentBoxSize
          • 值为一个数组,包含被监听元素的内容盒大小的对象
          • 数组中对象的属性于 borderBoxSize 中的相同
        • contentRect
          • 值为一个包含被监听元素大小的 DOMRectReadOnly 对象,包含属性 widthheight
            • 如果 target 是一个 HTML Element,则 contentRect 值是元素的内容盒
            • 如果 target 是一个 SVGElement,则 contentRectSVG 的边界框
          • 该属性是在 Resize Observer API 早期实现遗留下来的,比 borderBoxSizecotentBoxSize 有着更好的兼容性,在未来的版本中可能被弃用
        • devicePixelContentBoxSize
          • 值为一个数组,包含被监听元素的设备像素大小
          • 数组中对象的属性于 borderBoxSize 中的相同
        • target
          • 值为正在被监听的 ElementSVGElement 的引用
  • 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
      • 它是一个非零整数,是当前动画帧请求的唯一标识
      • 可以传递此 IDcancelAnimationFrame 方法以取消动画帧请求

基本过渡效果实现

使用以上 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.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),即可恢复正常的高度计算。

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

相关推荐
腾讯TNTWeb前端团队5 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰9 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪9 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪9 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy10 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom10 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom10 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom10 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom11 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom11 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试