实现 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),即可恢复正常的高度计算。

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

相关推荐
sg_knight5 分钟前
VSCode如何修改默认扩展路径和用户文件夹目录到D盘
前端·ide·vscode·编辑器·web
一个处女座的程序猿O(∩_∩)O14 分钟前
完成第一个 Vue3.2 项目后,这是我的技术总结
前端·vue.js
mubeibeinv15 分钟前
项目搭建+图片(添加+图片)
java·服务器·前端
逆旅行天涯21 分钟前
【Threejs】从零开始(六)--GUI调试开发3D效果
前端·javascript·3d
m0_7482552642 分钟前
easyExcel导出大数据量EXCEL文件,前端实现进度条或者遮罩层
前端·excel
web147862107231 小时前
C# .Net Web 路由相关配置
前端·c#·.net
m0_748247801 小时前
Flutter Intl包使用指南:实现国际化和本地化
前端·javascript·flutter
飞的肖1 小时前
前端使用 Element Plus架构vue3.0实现图片拖拉拽,后等比压缩,上传到Spring Boot后端
前端·spring boot·架构
青灯文案11 小时前
前端 HTTP 请求由 Nginx 反向代理和 API 网关到后端服务的流程
前端·nginx·http
m0_748254882 小时前
DataX3.0+DataX-Web部署分布式可视化ETL系统
前端·分布式·etl