實現(xiàn) height: auto 的元素的高度過渡動畫

對于一個 height 設(shè)置為 auto 的元素,當它的高度發(fā)生了不由樣式引起的改變時,并不會觸發(fā) transition 過渡動畫。

容器元素的高度往往是由其內(nèi)容決定的,如果一個容器元素的內(nèi)容高度突然發(fā)生了改變,而無法進行過渡動畫,有時會顯得比較生硬,比如下面的登錄框組件:

那么這種非樣式引起的變化如何實現(xiàn)過渡效果呢?可以借助 FLIP 技術(shù)。

FLIP 是什么

FLIPFirstLast,Invert,Play 的縮寫,其含義是:

  • First - 獲取元素變化之前的狀態(tài)
  • Last - 獲取元素變化后的最終狀態(tài)
  • Invert - 將元素從 Last 狀態(tài)反轉(zhuǎn)到 First 狀態(tài),比如通過添加 transform 屬性,使得元素變化后,看起來仍像是處于 First 狀態(tài)一樣
  • Play - 此時添加過渡動畫,再移除 Invert 效果(取消 transform),動畫就會開始生效,使得元素看起來從 First 過渡到了 Last

需要用到的 Web API

要實現(xiàn)一個基本的 FLIP 過渡動畫,需要使用到以下一些 Web API

基本過渡效果實現(xiàn)

使用以上 API,就可以初步實現(xiàn)一個監(jiān)聽元素尺寸變化,并對其應(yīng)用 FLIP 動畫的函數(shù) useBoxTransition,代碼如下:

/**
 *
 * @param {HTMLElement} el 要實現(xiàn)過渡的元素 DOM
 * @param {number} duration 過渡動畫持續(xù)時間,單位 ms
 * @returns 返回一個函數(shù),調(diào)用后取消對過渡元素尺寸變化的監(jiān)聽
 */
export default function useBoxTransition(el: HTMLElement, duration: number) {
  // boxSize 用于記錄元素處于 First 狀態(tài)時的尺寸大小
  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 發(fā)生尺寸變化時要進行的操作

      // 獲取當前回調(diào)調(diào)用時,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 尺寸發(fā)生變化時,使用 FLIP 動畫技術(shù)產(chǎn)生過渡動畫,使用過渡效果的是 scale 形變
      // 根據(jù) First 和 Last 計算出 Inverse 所需的 scale 大小
      // box 首次被觀察時會觸發(fā)一次回調(diào),此時 boxSize 為 null,scale 應(yīng)為 1
      const scaleX = boxSize ? boxSize.width / width : 1
      const scaleY = boxSize ? boxSize.height / height : 1
      // 尺寸發(fā)生變化的瞬間,要使用 scale 變形將其保持變化前的尺寸,要先將 transition 去除
      elStyle.setProperty('transition', 'none')
      elStyle.setProperty('transform', `scale(${scaleX}, ${scaleY})`)
      // 將 scale 移除,并應(yīng)用 transition 以實現(xiàn)過渡效果
      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
}

效果如下所示:

效果改進

目前已經(jīng)實現(xiàn)了初步的過渡效果,但在一些場景下會有些瑕疵:

  • 如果在過渡動畫完成前,元素有了新的狀態(tài)變化,則動畫被打斷,無法平滑過渡到新的狀態(tài)
  • FLIP 動畫過渡過程中,實際上發(fā)生變化的是 transform 屬性,并不影響元素在文檔流中占據(jù)的位置,如果需要該元素影響周圍的元素,那么周圍元素無法實現(xiàn)平滑過渡

如下所示:

對于動畫打斷問題的優(yōu)化思路

  • 使用 Window.requestAnimationFrame() 方法在每一幀中獲取元素的尺寸
  • 這樣做可以實時地獲取到元素的尺寸,實時地更新 First 狀態(tài)

對于元素在文檔流中問題的優(yōu)化思路

  • 應(yīng)用過渡的元素外可以套一個 .outer 元素,其定位為 relative,過渡元素的定位為 absolute,且居中于 .outer 元素
  • 當過渡元素尺寸發(fā)生變化時,通過 resizeObserver 獲取其最終的尺寸,將其寬高設(shè)置給 .outer 元素(實例代碼運行于 Vue 3 中,因此使用的是 Vue 提供的 ref api 將其寬高暴露出來,可以方便地監(jiān)聽其變化;如果在 React 中則可以將設(shè)置 .outer 元素寬高的方法作為參數(shù)傳入 useBoxTransition 中,在需要的時候調(diào)用),并給 .outer 元素設(shè)置寬高的過渡效果,使其在文檔流中所占的位置與過渡元素的尺寸同步
  • 但是也要注意,這樣做可能會引起瀏覽器高頻率的重排,在復(fù)雜布局中慎用!

改進后的useBoxTransition 函數(shù)如下:

import throttle from 'lodash/throttle'
import { ref } from 'vue'

type BoxSize = {
  width: number
  height: number
}
type BoxSizeRef = globalThis.Ref<BoxSize>

/**
 *
 * @param {HTMLElement} el 要實現(xiàn)過渡的元素 DOM
 * @param {number} duration 過渡動畫持續(xù)時間,單位 ms
 * @param {string} mode 過渡動畫緩動速率,同 CSS transition-timing-function 可選值
 * @returns 返回一個有兩個項的元組:第一項為 keyBoxSizeRef,當元素大小發(fā)生變化時會將變化后的目標尺寸發(fā)送給 keyBoxSizeRef.value;第二項為一個函數(shù),調(diào)用后取消對過渡元素尺寸變化的監(jiān)聽
 */
export default function useBoxTransition(
  el: HTMLElement,
  duration: number,
  mode?: string
) {
  let boxSizeList: BoxSize[] = [] // boxSizeList 表示對 box 的尺寸的記錄數(shù)組;為什么是使用列表:因為當 box 尺寸變化的一瞬間,box 的 transform 效果無法及時移除,此時 box 的尺寸可能是非預(yù)期的,因此使用列表來記錄 box 的尺寸,在必要的時候盡可能地將非預(yù)期的尺寸移除
  const keyBoxSizeRef: BoxSizeRef = ref({ width: 0, height: 0 }) // keyBoxSizeRef 是
  let isObserved = false // box 是否已經(jīng)開始被觀察
  let frameId = 0 // 當前 animationFrame 的 id
  let isTransforming = false // 當前是否處于變形過渡中

  const elStyle = el.style // el 的 CSSStyleDeclaration 對象
  const elComputedStyle = getComputedStyle(el) // el 的只讀動態(tài) CSSStyleDeclaration 對象

  // 獲取當前 boxSize 的函數(shù)
  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 的回調(diào)函數(shù),使得當 box 變形時可以更新 boxSize 記錄
  const animationFrameCallback = throttle(() => {
    // 為避免使用了函數(shù)節(jié)流后,導(dǎo)致回調(diào)函數(shù)延遲觸發(fā)使得 cancelAnimationFrame 失敗,因此使用 isTransforming 變量控制回調(diào)函數(shù)中的操作是否執(zhí)行
    if (isTransforming) {
      const boxSize = getBoxSize()
      updateBoxsize(boxSize)
      frameId = requestAnimationFrame(animationFrameCallback)
    }
  }, 20)

  // 過渡事件的回調(diào)函數(shù),在過渡過程中實時更新 boxSize
  function onTransitionStart(e: Event) {
    if (e.target !== el) return
    // 變形中斷的一瞬間,boxSize 的尺寸可能是非預(yù)期的,因此在變形開始時,將最新的幾個可能是非預(yù)期的 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 發(fā)生尺寸變化時要進行的操作

      // 獲取當前回調(diào)調(diào)用時,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 首次被觀察時會觸發(fā)一次回調(diào),此時不需要應(yīng)用過渡,只需將當前尺寸記錄到 boxSizeList 中
      if (!isObserved) {
        isObserved = true
        const boxSize = { width, height }
        boxSizeList.push(boxSize)
        keyBoxSizeRef.value = boxSize
        return
      }

      // 當 box 尺寸發(fā)生變化時,將此刻 box 的目標尺寸暴露給 keyBoxSizeRef
      keyBoxSizeRef.value = {
        width,
        height
      }

      // 當 box 尺寸發(fā)生變化時,使用 FLIP 動畫技術(shù)產(chǎn)生過渡動畫,使用過渡效果的是 scale 形變
      // 根據(jù) First 和 Last 計算出 Inverse 所需的 scale 大小
      const scaleX = boxSizeList[0].width / width
      const scaleY = boxSizeList[0].height / height
      // 尺寸發(fā)生變化的瞬間,要使用 scale 變形將其保持變化前的尺寸,要先將 transition 去除
      elStyle.setProperty('transition', 'none')
      const originalTransform =
        elStyle.transform || elComputedStyle.getPropertyValue('--transform')
      elStyle.setProperty(
        'transform',
        `${originalTransform} scale(${scaleX}, ${scaleY})`
      )
      // 將 scale 移除,并應(yīng)用 transition 以實現(xiàn)過渡效果
      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
}

相應(yīng)的 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>

優(yōu)化后的效果如下:

注意點

過渡元素本身的 transform 樣式屬性

useBoxTransition 函數(shù)中會覆蓋應(yīng)用過渡的元素的 transform 屬性,如果需要額外為元素設(shè)置其它的 transform 效果,需要使用 css 變量 --transform 設(shè)置,或使用內(nèi)聯(lián)樣式設(shè)置。

這是因為,useBoxTransition 函數(shù)中對另外設(shè)置的 transform 效果和過渡所需的 transform 效果做了合并。

然而通過 getComputedStyle(Element) 讀取到的 transform 的屬性值總是會被轉(zhuǎn)化為 matrix() 的形式,使得 transform 屬性值無法正常合并;而 CSS 變量和使用 Element.style 獲取到的內(nèi)聯(lián)樣式中 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.Style、getComputedStyle(Element) 獲取到的元素高度均缺少了 40px;而使用 ResizeObserverEntry 對象獲取到的高度則是正確的,但是它無法脫離 ResizeObserver API 獨立使用。

經(jīng)過測試驗證,缺少的 40px 高度來自于 el-tabs 組件中 .el-tabs__header 元素的高度,也就是說,在獲取元素高度時,將 .el-tabs__header 元素的高度忽略了。

測試后找出的解決方法是,手動將 .el-tabs__header 元素樣式(注意不要寫在帶 scoped 屬性的 style 標簽中,會被判定為布局樣式而無法生效)的 height 屬性指定為 calc(var(--el-tabs-header-height) - 1px),即可恢復(fù)正常的高度計算。

至于為什么這樣會造成高度計算錯誤,希望有大神能解惑。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容