本文是在组内的做的一个技术分享

先来看下 iOS 页面过渡动画的效果

ios-page-transition-1.gif

可以看到 Settings 这几个文字是从前一个页面平移到后一个页面的指定位置。如果这种动画让前端来实现的话,你会怎么实现?

UI 实现

UI 实现

朴素实现

将导航条组件提取到页面外层,成为页面的公共部分。然后根据不同页面的编写不同的导航条样式,添加不同的 className, 通过 CSS Transition 或者 CSS Animation 来实现动效。

这种方式的话,你的 App 组件大概会长这样

function App() {
  const pathname = useLocation().pathname
  const title = getTitle(pathname)
  const className = getClassName(pathname)

  return (
    <div className="App">
      <NavBar title={title} className={className} />
      <Routes>
        <Route path="/" element={<Home/>} />
        <Route path="/form" element={<Form/>} />
      </Routes>
    </div>
  )
}
function App() {
  const pathname = useLocation().pathname
  const title = getTitle(pathname)
  const className = getClassName(pathname)

  return (
    <div className="App">
      <NavBar title={title} className={className} />
      <Routes>
        <Route path="/" element={<Home/>} />
        <Route path="/form" element={<Form/>} />
      </Routes>
    </div>
  )
}

由于不同页面的 NarBar 里 title 的位置可能不一样,你其实很难编写样式。你要有一个基准的 title 的位置,然后根据不同页面,设置不同的 transform: translate(x, y) 去平移。假设有 A, B, C 三种样式,你就要实现 A -> B, B -> C, A -> C 三种动画。

最关键的是,这里面的 x, y 得通过设计稿标记出来的尺寸,去手动计算 因为设计稿是不会给你标注出来两个页面 title 之前的位置偏移量是多少的…如果动画再复杂一些,就更难实现了。

CSS Transition 动画实现

效果

demo-1.gif

实现一个动画,起点,终点,动画曲线三者缺一不可。我们有没有办法,只提供动画曲线,动画的起点和终点,让代码替我们算出来呢?

FLIP 技术

FLIP 技术 由 Paul Lewis 提出,FLIP 是 First、Last、Invert 和Play 四个单词首字母的缩写。

  • First: 动画的起始位置
  • Last: 动画的结束位置
  • Invert: 相反操作,通过 CSS 将动画从结束位置恢复到起始位置
  • Play: 将前一步添加的 CSS 样式去掉,加上 transition, 动画就会自动从起始位置播放到结束位置

FLIP 技术的原理是,JS 线程和渲染线程互斥,当你修改了 DOM 的 CSS 样式之后,浏览器不会立即渲染,而是等到主线程空闲出来,并且到达下一帧刷新时机时,渲染线程才会对你修改后的结果进行渲染。但是很关键的是,在修改完 DOM 之后,下一次渲染之前,你是可以拿到最新的渲染信息的

因此你可以拿到最新的渲染信息,求出旧的渲染信息的差异,然后再通过 CSS 将样式还原。乍一看一顿操作猛如虎,实际上下一帧渲染的结果和上一帧没有任何区别。假设修改 dom 前那一帧为 t, 修改 dom 后那一帧为 t+1。t 和 t+1 渲染结果是相同的。t+1渲染后,把 patch 的 CSS 去掉,同时加上 transition。到 t+2 时,浏览器就会开始执行 transition 动画。

你可以用浏览器打开 这个 html 文件 查看 demo。

React 实现 FlipAnimation 组件

基本思路

实现思路是这样的,由于 FLIP 动画需要比较 DOM 元素的动画前和动画后的样式差异,所以我们需要一个地方存这个样式,等到 DOM 更新后,重新获取样式,和之前存下的样式做比较,然后反转恢复成动画前的样式。

组件实现

我们实现两个组件,FlipAnimationProviderFlipAnimation,Provider 用来存储样式信息,FlipAnimation 会获取子元素的 DOM 样式,然后通过 Provider 提供的 API 存下来。存储是以 key, value 形式,因此 FlipAnimation 需要提供一个 id 作为key。

// 元素的样式存储结构是这样的
export type FlipConfig = {
  layout: {
    x: number
    y: number
    width: number
    height: number
  }
}

export function FlipAnimationProvider(props: { children: React.ReactNode }) {
  const [configs, setConfigs] = useState<Record<string, FlipConfig>>({})

  const value = useMemo(() => {
    const getFlipConfig = (id: string) => {
      return configs[id]
    }

    const setFlipConfig = (id: string, config: FlipConfig) => {
      setConfigs(prevConfigs => {
        return {
          ...prevConfigs,
          [id]: config,
        }
      })
    }

    return {
      getFlipConfig,
      setFlipConfig,
    }
  }, [configs])

  return <FlipConfigContext.Provider value={value}>{props.children}</FlipConfigContext.Provider>
}

type Props = {
  flipId: string
} & React.HTMLAttributes<HTMLDivElement>

function FlipAnimation(props: Props) {
  const { flipId, ...rest } = props
  const elRef = useRef<HTMLDivElement | null>(null)
  return <div ref={elRef} {...rest}></div>
}
// 元素的样式存储结构是这样的
export type FlipConfig = {
  layout: {
    x: number
    y: number
    width: number
    height: number
  }
}

export function FlipAnimationProvider(props: { children: React.ReactNode }) {
  const [configs, setConfigs] = useState<Record<string, FlipConfig>>({})

  const value = useMemo(() => {
    const getFlipConfig = (id: string) => {
      return configs[id]
    }

    const setFlipConfig = (id: string, config: FlipConfig) => {
      setConfigs(prevConfigs => {
        return {
          ...prevConfigs,
          [id]: config,
        }
      })
    }

    return {
      getFlipConfig,
      setFlipConfig,
    }
  }, [configs])

  return <FlipConfigContext.Provider value={value}>{props.children}</FlipConfigContext.Provider>
}

type Props = {
  flipId: string
} & React.HTMLAttributes<HTMLDivElement>

function FlipAnimation(props: Props) {
  const { flipId, ...rest } = props
  const elRef = useRef<HTMLDivElement | null>(null)
  return <div ref={elRef} {...rest}></div>
}

使用方式是这样

<FlipAnimationProvider>
    // ...
    <FlipAnimation flipId="xxx">
      <div>这里面随便写</div>
    </FlipAnimation>
    // ...
</FlipAnimationProvider>
<FlipAnimationProvider>
    // ...
    <FlipAnimation flipId="xxx">
      <div>这里面随便写</div>
    </FlipAnimation>
    // ...
</FlipAnimationProvider>

我们来看下最关键的对比样式差异的部分如何实现,当你的 dom 更新后,FlipAnimation 会调用 useLayoutEffect,这个时候 DOM 属性更新了,但还没渲染到屏幕上。获取 dom 的样式信息, 和存下来的样式比较,然后播放动画。

function FlipAnimation(props: Props) {
  const { flipId, ...rest } = props
  const elRef = useRef<HTMLDivElement | null>(null)
  const { getFlipConfig, setFlipConfig } = useFlipConfig() // provider 提供的函数

  // useLayoutEffect 会在 DOM 渲染到屏幕上之前也调用
  // 也就是前面说的 DOM 属性已经变更了,但还没渲染的这个 timing
  useLayoutEffect(() => {
    if (!elRef.current) {
      return
    }

    // 获取到子元素的 dom, 这里偷懒了用了firstElement 这种方式,实际上有友好好的 API 实现方式
    const container = elRef.current!.firstElementChild! as HTMLElement
    // 获取子元素 dom 的位置信息
    const boundRect = container.getBoundingClientRect()
    const oldConfig = getFlipConfig(flipId)

    const newConfig = {
      layout: {
        x: boundRect.x,
        y: boundRect.y,
        width: boundRect.width,
        height: boundRect.height,
      },
    }

    if (oldConfig) {
      // 有旧的样式存在,和新的比较然后播放动画
      flip(oldConfig, newConfig, container)
    }

    // 更新存储的样式
    setFlipConfig(flipId, newConfig)

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [flipId])

  return <div ref={elRef} {...rest}></div>
}

export function flip(
  from: FlipConfig,
  to: FlipConfig,
  container: HTMLElement
) {
  // 计算 DOM 动画前后位置差异
  const translateX = from.layout.x - to.layout.x
  const translateY = from.layout.y - to.layout.y
  const scaleX = from.layout.width / to.layout.width
  const scaleY = from.layout.height / to.layout.height

  // 将 DOM 平移到动画之前的位置的 transform
  const transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`

  const keyframes = [{ transform }, { transform: '' }]

  console.info('keyframes', keyframes)

  return new Promise<void>(resolve => {
    // 这里偷懒了,不是用 transition, 而是用 Web Animation API 做动画了
    container
      .animate(keyframes, {
        duration: PAGE_TRANSITION_DURATION,
        easing: 'ease',
      })
      .addEventListener('finish', function () {
        resolve()
      })
  })
}
function FlipAnimation(props: Props) {
  const { flipId, ...rest } = props
  const elRef = useRef<HTMLDivElement | null>(null)
  const { getFlipConfig, setFlipConfig } = useFlipConfig() // provider 提供的函数

  // useLayoutEffect 会在 DOM 渲染到屏幕上之前也调用
  // 也就是前面说的 DOM 属性已经变更了,但还没渲染的这个 timing
  useLayoutEffect(() => {
    if (!elRef.current) {
      return
    }

    // 获取到子元素的 dom, 这里偷懒了用了firstElement 这种方式,实际上有友好好的 API 实现方式
    const container = elRef.current!.firstElementChild! as HTMLElement
    // 获取子元素 dom 的位置信息
    const boundRect = container.getBoundingClientRect()
    const oldConfig = getFlipConfig(flipId)

    const newConfig = {
      layout: {
        x: boundRect.x,
        y: boundRect.y,
        width: boundRect.width,
        height: boundRect.height,
      },
    }

    if (oldConfig) {
      // 有旧的样式存在,和新的比较然后播放动画
      flip(oldConfig, newConfig, container)
    }

    // 更新存储的样式
    setFlipConfig(flipId, newConfig)

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [flipId])

  return <div ref={elRef} {...rest}></div>
}

export function flip(
  from: FlipConfig,
  to: FlipConfig,
  container: HTMLElement
) {
  // 计算 DOM 动画前后位置差异
  const translateX = from.layout.x - to.layout.x
  const translateY = from.layout.y - to.layout.y
  const scaleX = from.layout.width / to.layout.width
  const scaleY = from.layout.height / to.layout.height

  // 将 DOM 平移到动画之前的位置的 transform
  const transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`

  const keyframes = [{ transform }, { transform: '' }]

  console.info('keyframes', keyframes)

  return new Promise<void>(resolve => {
    // 这里偷懒了,不是用 transition, 而是用 Web Animation API 做动画了
    container
      .animate(keyframes, {
        duration: PAGE_TRANSITION_DURATION,
        easing: 'ease',
      })
      .addEventListener('finish', function () {
        resolve()
      })
  })
}

💡 我们 FlipAnimation 组件的容器是个固定的 div, 实际上可以通过高阶函数,创建所有的 DOM标签的 FlipAnimation 组件实现。

例如:

function createFlipAnimationComponent(tag: string) {
  return function FlipAnimation() {
    const elRef = useRef<HTMLDivElement | null>(null)
    // ...

    useLayoutEffect(() => {
    // ...
    }, [])

    return React.createElement(tag, { ref, ...rest })
  }
}

const FlipAnimation = {
  div: createFlipAnimationComponent('div'),
  h1: createFlipAnimationComponent('h1'),
}

// 使用
<FlipAnimation.h1 style={{ ... }}>
...
</FlipAnimation.h1>
function createFlipAnimationComponent(tag: string) {
  return function FlipAnimation() {
    const elRef = useRef<HTMLDivElement | null>(null)
    // ...

    useLayoutEffect(() => {
    // ...
    }, [])

    return React.createElement(tag, { ref, ...rest })
  }
}

const FlipAnimation = {
  div: createFlipAnimationComponent('div'),
  h1: createFlipAnimationComponent('h1'),
}

// 使用
<FlipAnimation.h1 style={{ ... }}>
...
</FlipAnimation.h1>

这样也不需要通过 firstElement 来取到子元素,可以直接拿 ref.current 做动画,用户样式直接写在 FlipAnimation 组件就好。

效果

demo-2.gif

Scale 产生变形

demo-3.gif

有一些样式不能简单的使用 scale 缩放。scale 会影响文字大小,圆角半径等等。无脑用 scale 会导致变形或者样式不符合预期。

diagram-2.png

border-radius 导致变形的示例可以在这篇博客查看。

为了解决这个问题,我们可以另外增加一个 aniamtionType 表示是字体的动画还是布局的动画。

如果是字体动画,我们就不用 scale,直接去获取字体相关的样式信息去做比较,然后做动画。

export type FlipConfig = {
  layout: {
    x: number
    y: number
    width: number
    height: number
  }
  // 增加文字相关的样式信息,这里只考虑 font-size 和 color
  font: {
    size?: number | undefined
    color?: string | undefined
  }
}

// FlipAnimation.tsx
export type AnimationType = 'size' | 'font'
type Props = {
  flipId: string
  animationType?: AnimationType
} & React.HTMLAttributes<HTMLDivElement>

useLayoutEffect(() => {
  // ...
   const newConfig = {
     layout: {
       x: boundRect.x,
       y: boundRect.y,
       width: boundRect.width,
       height: boundRect.height,
     },
     font: getFontStyle(container),
   }

   if (oldConfig) {
     flip(oldConfig, newConfig, container, animationType)
   }

  // ...
}, [flipId, animationType])

export function flip(
  from: FlipConfig,
  to: FlipConfig,
  container: HTMLElement,
  animationType: AnimationType = 'size'
 ) {
  // ...

  if (animationType === 'font') {
    scaleX = 1
    scaleY = 1
  }

  const keyframes = [
    { transform, fontSize: from.font.size + 'px', color: from.font.color },
    { transform: '', fontSize: to.font.size + 'px', color: to.font.color },
  ]

  // ...
}
export type FlipConfig = {
  layout: {
    x: number
    y: number
    width: number
    height: number
  }
  // 增加文字相关的样式信息,这里只考虑 font-size 和 color
  font: {
    size?: number | undefined
    color?: string | undefined
  }
}

// FlipAnimation.tsx
export type AnimationType = 'size' | 'font'
type Props = {
  flipId: string
  animationType?: AnimationType
} & React.HTMLAttributes<HTMLDivElement>

useLayoutEffect(() => {
  // ...
   const newConfig = {
     layout: {
       x: boundRect.x,
       y: boundRect.y,
       width: boundRect.width,
       height: boundRect.height,
     },
     font: getFontStyle(container),
   }

   if (oldConfig) {
     flip(oldConfig, newConfig, container, animationType)
   }

  // ...
}, [flipId, animationType])

export function flip(
  from: FlipConfig,
  to: FlipConfig,
  container: HTMLElement,
  animationType: AnimationType = 'size'
 ) {
  // ...

  if (animationType === 'font') {
    scaleX = 1
    scaleY = 1
  }

  const keyframes = [
    { transform, fontSize: from.font.size + 'px', color: from.font.color },
    { transform: '', fontSize: to.font.size + 'px', color: to.font.color },
  ]

  // ...
}

修复后

demo-4.gif

路由变化时页面过渡动效

在 React 中,我们都是使用 react-router-dom 来做路由系统。要实现页面过渡动画,必须得拥有在路由变化后的一段时间内,同时渲染跳转前和跳转后的页面的能力。但很遗憾 react-router-dom 没有提供这个功能,在路由变化后,旧的页面立刻卸载,新的页面立刻渲染,我们需要借助 react-transition-group 来实现。

接触过 Vue 的对 transition 组件应该都不陌生,react-transition-group 的用法类似。

起初我的想法是在每个 Route 组件外包一个 Transition 组件,就像对列表做动画一样。

<Routes>
  <TransitionGroup>
    <Transition key="/">
       <Route path="/" element={<Home />} />
    </Transition>
     <Transition key="/form">
       <Route path="/form" element={<Form />} />
    </Transition>
  </TransitionGroup>
</Routes>
<Routes>
  <TransitionGroup>
    <Transition key="/">
       <Route path="/" element={<Home />} />
    </Transition>
     <Transition key="/form">
       <Route path="/form" element={<Form />} />
    </Transition>
  </TransitionGroup>
</Routes>

但后来发现这样不行,因为用的是 react-router-v6,<Routes> 组件下如果不是 <Route> 组件会报错。

后来看了下 react-router-dom v5 的写法,发现v5 的 Switch 组件变成了v6 的 <Routes /> 组件,Switch 组件上有个 location 的 props, 于是写法改成了这样:

<TransitionGroup>
  <Transition  key={location.pathname}>
    <Routes location={location}>
      <Route path="/" element={<Home />} />
      <Route path="/form" element={<Form />} />
    </Routes>
  </Transition>
</TransitionGroup>
<TransitionGroup>
  <Transition  key={location.pathname}>
    <Routes location={location}>
      <Route path="/" element={<Home />} />
      <Route path="/form" element={<Form />} />
    </Routes>
  </Transition>
</TransitionGroup>

这个 location props 很关键,它可以覆盖掉当前页面的 history 的 location,否则就会从 context 上读 location。

假设现在页面 url 上的 path 是 /form,你传给一个字面量对象 {pathname: ‘/’},那下面的路由实际会渲染 <Home /> 而不是 <Form />

起初 v6 的 <Routes/> 是不支持这个 props 的,后来在几个 issue 的讨论下,最终又加上了。

React-transition-group 的原理大概就是,<Transition> 会缓存 children 的虚拟 dom,当 children 卸载时,在你指定的动画时间内,会渲染缓存下来的虚拟 dom,然后调用对应的 onEnter, onExit 之类的方法。

当虚拟dom被缓存时,它的 props 也是被缓存的。因此当页面从 / 跳到 /form,实际的渲染结果是这样的。

<TransitionGroup>
  <Transition  key="/">
    {/* 这里会匹配渲染出 <Home /> */}
    <Routes location={ { pathname: '/', ... } }>
      <Route path="/" element={<Home />} />
      <Route path="/form" element={<Form />} />
    </Routes>
  </Transition>

  <Transition  key="/form">
    {/* 这里会匹配渲染出 <Form /> */}
    <Routes location={ { pathname: '/form', ... } }>
      <Route path="/" element={<Home />} />
      <Route path="/form" element={<Form />} />
    </Routes>
  </Transition>
</TransitionGroup>
<TransitionGroup>
  <Transition  key="/">
    {/* 这里会匹配渲染出 <Home /> */}
    <Routes location={ { pathname: '/', ... } }>
      <Route path="/" element={<Home />} />
      <Route path="/form" element={<Form />} />
    </Routes>
  </Transition>

  <Transition  key="/form">
    {/* 这里会匹配渲染出 <Form /> */}
    <Routes location={ { pathname: '/form', ... } }>
      <Route path="/" element={<Home />} />
      <Route path="/form" element={<Form />} />
    </Routes>
  </Transition>
</TransitionGroup>

react-transition-group.png

知道怎么同时渲染两个页面后,我们要实现页面的过渡动画了。我们的场景里页面过渡动画有四种:

  • 返回上一个页面时,当前页面往右滑出
  • 返回上一个页面时,新页面从左边进来
  • 跳转到新页面时,新页面从右边滑进来
  • 跳转到新页面时,老页面从左边滑出

Transition 组件提供了 onEnter, onExit 等钩子,react-router-dom 提供了 navigationType 表示当前跳转还是 push/pop/replace,我们结合 navigationTypeonEnter, onExit 就可以组合出这四种动画。

class AnimateRoutesClass extends React.Component<Props> {
  constructor(props: Props) {
    super(props)
    this.handleEnter = this.handleEnter.bind(this)
    this.handleExit = this.handleExit.bind(this)
  }

  handleEnter(node: HTMLElement) {
    const { navigationType } = this.props

    if (navigationType === 'POP') {
      leftIn(node)
    } else {
      rightIn(node)
    }
  }

  handleExit(node: HTMLElement) {
    const { navigationType } = this.props

    if (navigationType === 'POP') {
      rightOut(node)
    } else {
      leftOut(node)
    }
  }

  render() {
    const { location, children } = this.props

    return (
      <TransitionGroup>
        <Transition
          key={location.pathname}
          timeout={PAGE_TRANSITION_DURATION}
          onEnter={this.handleEnter}
          onExit={this.handleExit}
        >
          <Routes location={location}>{children}</Routes>
        </Transition>
      </TransitionGroup>
    )
  }
}
class AnimateRoutesClass extends React.Component<Props> {
  constructor(props: Props) {
    super(props)
    this.handleEnter = this.handleEnter.bind(this)
    this.handleExit = this.handleExit.bind(this)
  }

  handleEnter(node: HTMLElement) {
    const { navigationType } = this.props

    if (navigationType === 'POP') {
      leftIn(node)
    } else {
      rightIn(node)
    }
  }

  handleExit(node: HTMLElement) {
    const { navigationType } = this.props

    if (navigationType === 'POP') {
      rightOut(node)
    } else {
      leftOut(node)
    }
  }

  render() {
    const { location, children } = this.props

    return (
      <TransitionGroup>
        <Transition
          key={location.pathname}
          timeout={PAGE_TRANSITION_DURATION}
          onEnter={this.handleEnter}
          onExit={this.handleExit}
        >
          <Routes location={location}>{children}</Routes>
        </Transition>
      </TransitionGroup>
    )
  }
}

具体的动画逻辑就是常规的 transform 平移。

function rightIn(node: HTMLElement) {
  node.style.transform = 'translateX(100%)'

  const task = () => {
    node
      .animate([{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }], {
        duration: PAGE_TRANSITION_DURATION,
        easing: 'ease',
      })
      .addEventListener('finish', function (){
        node.style.transform = ''
      })
  }

  pushTask(task)
}

function leftOut(node: HTMLElement) {
  const task = () => {
    node
      .animate([{ transform: 'translateX(0%)' }, { transform: 'translateX(-50%)' }], {
        duration: PAGE_TRANSITION_DURATION,
        easing: 'ease',
      })
      .addEventListener('finish', function () {
        node.style.display = 'none'
      })
  }
  pushTask(task)
}

function leftIn(node: HTMLElement) {
  node.style.transform = 'translateX(-50%)'

  const task = () => {
    node
      .animate([{ transform: 'translateX(-50%)' }, { transform: 'translateX(0%)' }], {
        duration: PAGE_TRANSITION_DURATION,
        easing: 'ease',
      })
      .addEventListener('finish', function () {
        node.style.transform = ''
      })
  }

  pushTask(task)
}

function rightOut(node: HTMLElement) {
  const task = () => {
    node.animate(
      [
        { transform: 'translateX(0%)', opacity: 1 },
        { transform: 'translateX(100%)', opacity: 0 },
      ],
      {
        duration: PAGE_TRANSITION_DURATION,
        easing: 'ease',
      }
    )
    .addEventListener('finish', function () {
      node.style.display = 'none'
    })
  }

  pushTask(task)
}
function rightIn(node: HTMLElement) {
  node.style.transform = 'translateX(100%)'

  const task = () => {
    node
      .animate([{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }], {
        duration: PAGE_TRANSITION_DURATION,
        easing: 'ease',
      })
      .addEventListener('finish', function (){
        node.style.transform = ''
      })
  }

  pushTask(task)
}

function leftOut(node: HTMLElement) {
  const task = () => {
    node
      .animate([{ transform: 'translateX(0%)' }, { transform: 'translateX(-50%)' }], {
        duration: PAGE_TRANSITION_DURATION,
        easing: 'ease',
      })
      .addEventListener('finish', function () {
        node.style.display = 'none'
      })
  }
  pushTask(task)
}

function leftIn(node: HTMLElement) {
  node.style.transform = 'translateX(-50%)'

  const task = () => {
    node
      .animate([{ transform: 'translateX(-50%)' }, { transform: 'translateX(0%)' }], {
        duration: PAGE_TRANSITION_DURATION,
        easing: 'ease',
      })
      .addEventListener('finish', function () {
        node.style.transform = ''
      })
  }

  pushTask(task)
}

function rightOut(node: HTMLElement) {
  const task = () => {
    node.animate(
      [
        { transform: 'translateX(0%)', opacity: 1 },
        { transform: 'translateX(100%)', opacity: 0 },
      ],
      {
        duration: PAGE_TRANSITION_DURATION,
        easing: 'ease',
      }
    )
    .addEventListener('finish', function () {
      node.style.display = 'none'
    })
  }

  pushTask(task)
}

DOM 属性计算时机

看上面的代码发现多了个 pushTask 的函数调用,这个是干嘛的呢?原因是 react-transition-group 的实现使用 class 组件,依赖的生命周期方法是 componentDidMount,componentDidUpdate 等。我们 FlipAnimation 组件当中使用的是 useLayoutEffect。他们的调用顺序是:

  1. useLayoutEffect
  2. componentDidMount
  3. useEffect

也就是说,我们的页面过渡动画逻辑是在 componentDidMount 这个时间点做的,例如在 rightIn 会先通过 node.style.transform = 'translateX(100%)' 将新页面移动到屏幕外,再播放从屏幕右侧滑进来的动画。

而当 useLayoutEffect 去做 flip 动画的一些事情的,此时我们的组件还没有被移动到屏幕,因此此时计算出来的终点位置是错误的,正确的终点位置应该是在屏幕外的某个地方

diagram-3.png

因此我们的 FlipAniamtion 组件不能在 useLayoutEffect 里面做动画了,得改成 useEffect,在 Transition 组件的 componentDidMount 之后计算才能得到正确的位置信息。但是由于 useEffect 调用时间和 componentDidMount 调用的时间间隔差距有点大,会导致页面过渡动画和flip动画是一前一后开始,而不是同时,因此引入一个任务调度的机制,同时执行动画逻辑,缓解这个时机不一致造成的动画先后顺序问题。

Chrome 无任务调度的效果

demo-5.gif

通过任务调度同步动画的调用时机

任务调度的机制实现非常简单, pushTask 推到任务队列里,然后通过flushTasks在下一帧清空并执行任务。

let tasks: Array<() => void> = []
let rid: number | null = null

export function pushTask(task: () => void) {
  tasks.push(task)
}

export function flushTasks() {
  if (rid !== null) {
    return
  }
  rid = requestAnimationFrame(() => {
    tasks.forEach(task => task())
    tasks = []
    rid = null
  })
}

// FlipAnimation.tsx
// useLayoutEffect 改成了 useEffect
useEffect(() => {
 // ...
 if (oldConfig) {
     flip(oldConfig, newConfig, container, animationType).then(() => {
       // 由于页面过渡动画的缘故,DOM 的最终位置实际发生了改变
       // 因此在动画结束后重新获取最新的位置信息
       const boundRect = container.getBoundingClientRect()
       setFlipConfig(flipId, {
          layout: {
            x: boundRect.x,
            y: boundRect.y,
            width: boundRect.width,
            height: boundRect.height,
          },
          font: getFontStyle(container),
       })
     })

    flushTasks() // 启动动画
 }

}, [])

export function flip(
  from: FlipConfig,
  to: FlipConfig,
  container: HTMLElement,
  animationType: AnimationType = 'size'
) {
  // ....
  return new Promise<void>(resolve => {
    pushTask(() => {
      container
        .animate(keyframes, {
          duration: PAGE_TRANSITION_DURATION,
          easing: 'ease',
        })
        .addEventListener('finish', function () {
          resolve() // 这里resolve 之后外面会调 flushTask
        })
    })
  })
}
let tasks: Array<() => void> = []
let rid: number | null = null

export function pushTask(task: () => void) {
  tasks.push(task)
}

export function flushTasks() {
  if (rid !== null) {
    return
  }
  rid = requestAnimationFrame(() => {
    tasks.forEach(task => task())
    tasks = []
    rid = null
  })
}

// FlipAnimation.tsx
// useLayoutEffect 改成了 useEffect
useEffect(() => {
 // ...
 if (oldConfig) {
     flip(oldConfig, newConfig, container, animationType).then(() => {
       // 由于页面过渡动画的缘故,DOM 的最终位置实际发生了改变
       // 因此在动画结束后重新获取最新的位置信息
       const boundRect = container.getBoundingClientRect()
       setFlipConfig(flipId, {
          layout: {
            x: boundRect.x,
            y: boundRect.y,
            width: boundRect.width,
            height: boundRect.height,
          },
          font: getFontStyle(container),
       })
     })

    flushTasks() // 启动动画
 }

}, [])

export function flip(
  from: FlipConfig,
  to: FlipConfig,
  container: HTMLElement,
  animationType: AnimationType = 'size'
) {
  // ....
  return new Promise<void>(resolve => {
    pushTask(() => {
      container
        .animate(keyframes, {
          duration: PAGE_TRANSITION_DURATION,
          easing: 'ease',
        })
        .addEventListener('finish', function () {
          resolve() // 这里resolve 之后外面会调 flushTask
        })
    })
  })
}

Chrome 有任务调度效果

demo-6.gif

Safari 效果

demo-7.gif

其实加入了这个任务调度机制后,也不是非常稳定,因为我没有测试过也不确定 useEffect 具体的调用时机,它调用的时机是不是稳定的?它是在 requestAnimation 之前还是之后?如果是 raf 之前还好说,如果是之后的话,那这个任务调度只能是减少一点点时间误差而已。

实际演示的效果是,chrome 上面前几次可能会出现动画运动曲线不稳定的 case,safari 和 firefox 相对好一点。

隐藏离开页面的 flip 动画元素

因为新老页面同时渲染,所以做 flip动画的 DOM 也存在两个,且新页面的 flip DOM 元素会移动到老页面的位置,因此我们需要把老页面的的 DOM 给隐藏掉。

screenshot-1.png

这个可以使用 react-router-dom 提供的 useMatch hook 简单判断下是否命中就可以做到。

const match = useMatch('/')

<FlipAnimation style={{visibility: !match ? 'hidden' : 'visible'}}>
  <div>...<div>
<FlipAnimation/>
const match = useMatch('/')

<FlipAnimation style={{visibility: !match ? 'hidden' : 'visible'}}>
  <div>...<div>
<FlipAnimation/>

导航条 Fixed 形态下的 flip 动画

我们再来看看 iOS 页面往下滚动时导航条会有什么变化。

ios-page-transition-2.gif

可以看到,当页面往下滚时,导航条样式变了,会固定在最上面,然后跳转页面时,标题文字是直接从中间平移到左边的。后续页面跳转时,文字是从中间平移到返回图标的右侧,我们来实现一下这个效果。

Fixed 形态

滚动后固定在上方这部分具体的样式就不细说了,很常规的实现,可以看 demo 仓库里的源码。主要要注意几个问题:

  1. 滚动后样式变成 position:fixed, DOM 的位置变了,因此需要更新我们组件mount 后在 useEffect 里缓存的 flipConfig 样式配置信息。
  2. 当你往下滚动,然后跳转到新页面时,滚动条需要恢复到最上方。当返回时,滚动条需要恢复到原来滚动的位置。
  3. 我们用了position:fixed。但是我们页面的过渡动效是用transform 从左边或者右边滑进来的。如果父元素用了 transform, 子元素的 position: fixed 的就不会根据屏幕来定位了,行为更类似于 position: absolute;

更新 DOM 样式信息

解决方式是,在每次样式变更后,都重新获取 DOM 样式信息更新 flipConfig。我们可以使用 MutationObserver, IntersectionObserver 等 API 去监听 DOM 变更,然后更新信息。但在这里我偷懒了,我直接让 FlipAnimation 暴露了一个 updateConfig 出来,然后父组件修改样式后,调 updateConfig 来更新信息。

// FlipAnimation.tsx

// ...
const updateConfig = useCallback(() => {
    const container = elRef.current!.firstElementChild! as HTMLElement
    const boundRect = container.getBoundingClientRect()
    const newConfig = {
      layout: {
        x: boundRect.x,
        y: boundRect.y,
        width: boundRect.width,
        height: boundRect.height,
      },
      font: getFontStyle(container),
    }
    setFlipConfig(flipId, newConfig)
    // eslint-disable-next-line react-hooks/exhaustive-deps
}, [flipId, elRef])

useImperativeHandle(ref, () => {
  return {
    updateConfig,
  }
})

// ...

// Home.tsx
// ...
useEffect(() => {
  // 当 isFixed 为 true 时,会将导航条改为 position:fixed;
  // 此时需要更新缓存的样式信息
  flipRef.current.updateConfig()
}, [isFixed])

<FlipAnimation ref={flipRef} flipId="home" animationType="font">
    <h1 className="title" style={{ fontSize: isFixed ? 18 : 48, height: isFixed ? 26 : 48 }}>
      Home
    </h1>
</FlipAnimation>
// FlipAnimation.tsx

// ...
const updateConfig = useCallback(() => {
    const container = elRef.current!.firstElementChild! as HTMLElement
    const boundRect = container.getBoundingClientRect()
    const newConfig = {
      layout: {
        x: boundRect.x,
        y: boundRect.y,
        width: boundRect.width,
        height: boundRect.height,
      },
      font: getFontStyle(container),
    }
    setFlipConfig(flipId, newConfig)
    // eslint-disable-next-line react-hooks/exhaustive-deps
}, [flipId, elRef])

useImperativeHandle(ref, () => {
  return {
    updateConfig,
  }
})

// ...

// Home.tsx
// ...
useEffect(() => {
  // 当 isFixed 为 true 时,会将导航条改为 position:fixed;
  // 此时需要更新缓存的样式信息
  flipRef.current.updateConfig()
}, [isFixed])

<FlipAnimation ref={flipRef} flipId="home" animationType="font">
    <h1 className="title" style={{ fontSize: isFixed ? 18 : 48, height: isFixed ? 26 : 48 }}>
      Home
    </h1>
</FlipAnimation>

页面跳转滚动条处理

一般情况下,使用react-router-dom时不太需要处理滚动条的逻辑,但是和 react-transition-group 一起用后,由于 rtg 会缓存虚拟 dom,导致滚动条出现了一些问题。 比如:

  1. 跳转到新页面,滚动条不会变成 0
  2. 返回上一个页面,滚动条一开始是 0,过一会儿又会突然变回原来的位置

第一个问题,我们只要在新页面滚到 0 就好了

useLayoutEffect(() => {
  window.scrollTo(0, 0)
}, [])
useLayoutEffect(() => {
  window.scrollTo(0, 0)
}, [])

第二个问题,其实正常情况下滚动条变成0再变回来也不是大问题,但是我们需要这个滚动条信息去更新我们 isFixed 的状态,先是 0 的话会导致 isFixed 状态不对,然后样式不对,然后 FlipAnimation 计算出来的样式信息也不对,动画会出问题。为了简单,我直接用了一个外部变量去记录跳转前滚动条的信息,然后调回来的时候优先使用这个变量来设置 isFixed 状态。

let scrollTop = 0

function useFixed() {
  const [isFixed, setIsFixed] = useState(scrollTop > 40)

  useEffect(() => {
    const handler = () => {
      if (document.documentElement.scrollTop > 0 || scrollTop < 40) {
        // 页面跳转前记住滚动条高度, 跳转后会突然变成0, 要记住变成0之前的值
        scrollTop = document.documentElement.scrollTop
      }

      if (document.documentElement.scrollTop > 40) {
        if (!isFixed) {
          setIsFixed(true)
        }
      } else if (isFixed) {
        setIsFixed(false)
      }
    }

    window.addEventListener('scroll', handler)

    return () => {
      window.removeEventListener('scroll', handler)
    }
  }, [isFixed])

  return isFixed
}
let scrollTop = 0

function useFixed() {
  const [isFixed, setIsFixed] = useState(scrollTop > 40)

  useEffect(() => {
    const handler = () => {
      if (document.documentElement.scrollTop > 0 || scrollTop < 40) {
        // 页面跳转前记住滚动条高度, 跳转后会突然变成0, 要记住变成0之前的值
        scrollTop = document.documentElement.scrollTop
      }

      if (document.documentElement.scrollTop > 40) {
        if (!isFixed) {
          setIsFixed(true)
        }
      } else if (isFixed) {
        setIsFixed(false)
      }
    }

    window.addEventListener('scroll', handler)

    return () => {
      window.removeEventListener('scroll', handler)
    }
  }, [isFixed])

  return isFixed
}

position:fixed 和 transform 一起用

不知道怎么办,于是我把返回时的页面入场动画去掉了。

// AnimationRoutes.tsx

handleEnter(node: HTMLElement) {
  const { navigationType } = this.props

  if (navigationType === 'POP') {
    // 使用 fixed 时不能用 translate, 不然会变成 absolute
    // leftIn(node)
  } else {
    rightIn(node)
  }
}
// AnimationRoutes.tsx

handleEnter(node: HTMLElement) {
  const { navigationType } = this.props

  if (navigationType === 'POP') {
    // 使用 fixed 时不能用 translate, 不然会变成 absolute
    // leftIn(node)
  } else {
    rightIn(node)
  }
}

效果

demo-8.gif

后续页面跳转的标题动画

前面我们每个页面都只有一个 FlipAnimation 组件,当中间的页面实际上有两个地方需要 flip 动画。即下图圈住的两个地方。

screenshot-2.png

于是我们需要修改一下之前的组件。

// Home.tsx
<FlipAnimation ref={flipRef} flipId="settings" animationType="font">
  <h1 className="title" style={{ fontSize: isFixed ? 18 : 48, height: isFixed ? 26 : 48 }}>
    Settings
  </h1>
</FlipAnimation>

// Form.tsx
// 返回按钮
<div className="flex h-12 w-12 justify-center items-center" onClick={() => navigate(-1)}>
    <IconLucideChevronLeft className=" text-[32px]" />
</div>

<FlipAnimation flipId="settings" animationType="font">
  <h1 className="title" style={{ fontSize: 18, height: 26, color: '#306ee8' }}>
    Settings
  </h1>
</FlipAnimation>

<FlipAnimation flipId="general" animationType="font">
  <div style={{ marginLeft: 20, fontSize: 24, color: '#00000' }}>General</div>
</FlipAnimation>

// About.tsx 从 Form.tsx 复制过来的新页面
<div className="flex h-12 w-12 justify-center items-center" onClick={() => navigate(-1)}>
   <IconLucideChevronLeft className=" text-[32px]" />
</div>
<FlipAnimation flipId="general" animationType="font">
  <h1 className="title" style={{ fontSize: 18, height: 26, color: '#306ee8' }}>
    General
  </h1>
</FlipAnimation>

<div style={{ marginLeft: 20, fontSize: 24, color: '#00000' }}>About</div>
// Home.tsx
<FlipAnimation ref={flipRef} flipId="settings" animationType="font">
  <h1 className="title" style={{ fontSize: isFixed ? 18 : 48, height: isFixed ? 26 : 48 }}>
    Settings
  </h1>
</FlipAnimation>

// Form.tsx
// 返回按钮
<div className="flex h-12 w-12 justify-center items-center" onClick={() => navigate(-1)}>
    <IconLucideChevronLeft className=" text-[32px]" />
</div>

<FlipAnimation flipId="settings" animationType="font">
  <h1 className="title" style={{ fontSize: 18, height: 26, color: '#306ee8' }}>
    Settings
  </h1>
</FlipAnimation>

<FlipAnimation flipId="general" animationType="font">
  <div style={{ marginLeft: 20, fontSize: 24, color: '#00000' }}>General</div>
</FlipAnimation>

// About.tsx 从 Form.tsx 复制过来的新页面
<div className="flex h-12 w-12 justify-center items-center" onClick={() => navigate(-1)}>
   <IconLucideChevronLeft className=" text-[32px]" />
</div>
<FlipAnimation flipId="general" animationType="font">
  <h1 className="title" style={{ fontSize: 18, height: 26, color: '#306ee8' }}>
    General
  </h1>
</FlipAnimation>

<div style={{ marginLeft: 20, fontSize: 24, color: '#00000' }}>About</div>

其实这样就已经实现了动画,但是多跳转几次就会发现 bug。这里问题有两个:

  1. 从 Form.tsx 页面过渡动画结束后,Form 页面里两个 <FlipAnimation> 组件的位置其实都已经改变了,和前面导航条变成 Fixed 一样,需要更新缓存的样式信息。
  2. 当页面卸载后,flipConfig 没有清除。比如当你跳转到 About 页面后,页面上没有一个flipId 为 settings 的 FlipAnimation 组件,此时在 Home 页面 flipId 为 settings 的这个 DOM 样式信息应该清理掉,否则返回 Form 页面时,settings 这个配置还是和原来一样,就不会有动画出现了。我们可以用一个简单的计数器来记录缓存的样式有没有被当前页面上的 FlipAnimation 组件所依赖,没有的话就删除缓存。
// FlipAnimationProvider.tsx
const countRef = useRef<Record<string, number>>({})
const count = useCallback((id: string) => {
  if (!countRef.current[id]) {
    countRef.current[id] = 0
  }
  // 计数器增加
  countRef.current[id]++

  return () => {
    // 计数器递减
    countRef.current[id]--
    // 计数器到 0 时删除配置
    if (countRef.current[id] === 0) {
      delete countRef.current[id]
      setConfigs(config => {
        const { [id]: a, ...rest } = config
        return {
         ...rest,
        }
      })
    }
  }
}, [])

// FlipAnimation.tsx

useEffect(() => {
  // 不想设计了,怎么简单怎么来了,很粗暴,页面动效结束后直接更新配置
  setTimeout(() => {
    updateConfig()
  }, PAGE_TRANSITION_DURATION)

}, [])

useEffect(() => {
  // 页面 mount 时计数器增加,unMount 时计数器递减
  return count(flipId)
}, [])
// FlipAnimationProvider.tsx
const countRef = useRef<Record<string, number>>({})
const count = useCallback((id: string) => {
  if (!countRef.current[id]) {
    countRef.current[id] = 0
  }
  // 计数器增加
  countRef.current[id]++

  return () => {
    // 计数器递减
    countRef.current[id]--
    // 计数器到 0 时删除配置
    if (countRef.current[id] === 0) {
      delete countRef.current[id]
      setConfigs(config => {
        const { [id]: a, ...rest } = config
        return {
         ...rest,
        }
      })
    }
  }
}, [])

// FlipAnimation.tsx

useEffect(() => {
  // 不想设计了,怎么简单怎么来了,很粗暴,页面动效结束后直接更新配置
  setTimeout(() => {
    updateConfig()
  }, PAGE_TRANSITION_DURATION)

}, [])

useEffect(() => {
  // 页面 mount 时计数器增加,unMount 时计数器递减
  return count(flipId)
}, [])

最终效果

未解决的问题

  • 页面层级问题,返回页面的时候,导航条是渲染在 Home 组件里的,Home 组件的 z-index 要低,因此导航条实际上是会被离开的页面挡住的,这里用 opacity 作 workaround,缺点就是会有轻微闪烁。尝试过用子路由来做,但是 react-router-dom v6 <Outlet/> 渲染子路由做不到类似 <Routes location />,因此 react-transition-group 的动效也没法做,就放弃了。目前想到的办法就是,导航条如果都是 fixed 的,那么页面不要给直接给 background,而是在导航条下方的那部分给background。这样的话,导航条是透明的,可以看到下面的新页面。而页面下半部分是有背景色的,可以挡住下面新页面的内容。
  • Transition 和 fixed 一起用的问题,要不就页面转场动画就不用transform, 直接用 left, right 来做,就是性能可能会比较差一些。
  • 字体动画还是不稳定,怀疑还是因为flip动画和页面过渡动画的时机不同步的原因,具体上面说了。
  • 页面过渡动画的取消功能,如果页面过渡动画过程中又跳转了新路由,现在的实现是存在问题的。

仓库

本文完。