Nextjs+Frame Motion 实现页面过度动画

1,041 阅读2分钟

所用依赖版本

  • "framer-motion": "^11.2.10"
  • "next": "14.2.3",
  • "react": "^18",
  • "react-dom": "^18"

大致效果

Jietu20240608-181254-HD-ezgif.com-video-to-gif-converter.gif

创建项目

  • 运行npx create-next-app命令创建,并选择下图中的相关配置(需要使用App Router以及Tailwind CSS,这两个必须选),其它配置随意。

image.png

  • 运行pnpm add framer-motion安装Frame Motion。

具体实现

创建components/TransitionCurve.tsx

该文件主要是通过AnimatePresence管理组件的进入和退出动画,mode="wait"表示在前一个子组件的退出动画完成后再应用下一个子组件的进入动画。

motion.svg主要用于动态生成 SVG 路径,就是我们看到的动画。

废话不多说,具体代码如下:

'use client'

import type { MotionProps } from 'framer-motion'
import { AnimatePresence, motion } from 'framer-motion'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { FrozenRouter } from './FrozenRouter'
import CurrentPage from './CurrentPage'


export const text: MotionProps['variants'] = {
  initial: {
    opacity: 1
  },
  enter: {
    opacity: 0,
    top: -100,
    transition: { duration: 0.75, delay: 0.35, ease: [0.76, 0, 0.24, 1] },
    transitionEnd: { top: '47.5%' }
  },
  exit: {
    opacity: 1,
    top: '40%',
    transition: { duration: 0.5, delay: 0.4, ease: [0.33, 1, 0.68, 1] }
  }
}

export function curve(initialPath: string, targetPath: string): MotionProps['variants'] {
  return {
    initial: {
      d: initialPath
    },
    enter: {
      d: targetPath,
      transition: { duration: 0.75, delay: 0.35, ease: [0.76, 0, 0.24, 1] }
    },
    exit: {
      d: initialPath,
      transition: { duration: 0.75, ease: [0.76, 0, 0.24, 1] }
    }
  }
}

export const translate: MotionProps['variants'] = {
  initial: {
    top: '-300px'
  },
  enter: {
    top: '-100vh',
    transition: { duration: 0.75, delay: 0.35, ease: [0.76, 0, 0.24, 1] },
    transitionEnd: {
      top: '100vh'
    }
  },
  exit: {
    top: '-300px',
    transition: { duration: 0.75, ease: [0.76, 0, 0.24, 1] }
  }
}

function anim(variants: MotionProps['variants']): MotionProps {
  return {
    variants,
    initial: 'initial',
    animate: 'enter',
    exit: 'exit'
  }
}

function SVG({ height, width }: { width: number | null; height: number | null }) {
  if (!(height && width)) return
  const initialPath = `
        M0 300 
        Q${width / 2} 0 ${width} 300
        L${width} ${height + 300}
        Q${width / 2} ${height + 600} 0 ${height + 300}
        L0 0
    `

  const targetPath = `
        M0 300
        Q${width / 2} 0 ${width} 300
        L${width} ${height}
        Q${width / 2} ${height} 0 ${height}
        L0 0
    `

  return (
    <motion.svg
      {...anim(translate)}
      className="fixed h-[calc(100vh+600px)] w-screen pointer-events-none left-0 top-0 z-20"
    >
      <motion.path {...anim(curve(initialPath, targetPath))} />
    </motion.svg>
  )
}

export default function Transition({ children }: { children: React.ReactNode }) {
  const key = usePathname()

  const [dimensions, setDimensions] = useState<{ width: number | null; height: number | null }>({
    width: null,
    height: null
  })

  useEffect(() => {
    function resize() {
      setDimensions({
        width: window.innerWidth,
        height: window.innerHeight
      })
    }
    resize()
    window.addEventListener('resize', resize)
    return () => {
      window.removeEventListener('resize', resize)
    }
  }, [])

  return (
    <AnimatePresence mode="wait">
      <div key={key}>
        <div
          style={{ opacity: dimensions.width == null ? 1 : 0 }}
          className="fixed h-[calc(100vh+600px)] w-screen pointer-events-none left-0 top-0 z-20"
        />
        <motion.p
          {...anim(text)}
          className="absolute w-screen top-[40%] text-white text-5xl z-30 text-center"
        >
          <CurrentPage />
        </motion.p>
        {dimensions.width != null && <SVG {...dimensions} />}
        <FrozenRouter>{children}</FrozenRouter>
      </div>
    </AnimatePresence>
  )
}

创建components/FrozenRouter.tsx

这个文件的作用,是保证动画的正常进行,如果没有这个文件,路由切换的时候,页面会先跳转,然后再出现动画。

具体问题可以查看这个链接: stackoverflow.com/questions/7…

代码如下:

'use client'

import { LayoutRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
import { useContext, useRef } from 'react'

export function FrozenRouter(props: { children: React.ReactNode }) {
  const context = useContext(LayoutRouterContext ?? {})
  const frozen = useRef(context)

  if (!frozen.current) {
    return <>{props.children}</>
  }

  return (
    <LayoutRouterContext.Provider value={frozen.current}>{props.children}</LayoutRouterContext.Provider>
  )
}

创建components/CurrentPage.tsx,该组件主要用途是,在切换的时候显示即将去的页面名称。

具体代码如下:

import { usePathname } from 'next/navigation'
import React from 'react'

export default function CurrentPage() {
  const path = usePathname()
  const routes: Record<string, string> = {
    '/': 'Home',
    '/about': 'About',
    '/contact': 'Contact'
  }
  return <span>{routes[path]}</span>
}

在layout中使用

import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import Link from 'next/link'
import TransitionEase from '@/components/TransitionEase'
import TransitionCurve from '@/components/TransitionCurve'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app'
}

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <header className="fixed z-[12] top-0 left-0 w-screen h-8">
          <Link href="/">Home</Link>
          <Link href="/about">About</Link>
        </header>
        <TransitionCurve>{children}</TransitionCurve>
      </body>
    </html>
  )
}


总结

本文大致介绍了如何在Next.js中使用 Framer Motion 库创建一个页面过渡动画组件。使用FrozenRouter处理App router中Frame motion 动画触发的问题。