使用react-router 6.14.x 和 framer-motion 实现路由过场动画

Framer-motion这个库,是我体验过的最好用的React生态下的交互动效库, 它不仅可以实现很多交互动画或者元素动画,而且性能很好,重点是它还可以结合react-router(v5/v6)实现路由过场动画效果,今天它来了~

oneline demo

首先来看一下效果,gif的效果有点差

下面是具体的实现步骤:

准备工作

  • 首先我们使用vite初始化空的react-ts模版

    安装步骤可参考官方网站

  • 安装必要npm pkgs,这里我们用到了react-router(v6) & framer-motion

    npm i framer-motion react-router react-router-dom -S
    npm i sass -D
    
  • 创建两个page,分别是pages/Home, pages/About

    mkdir src/pages && touch src/pages/Home.tsx src/pages/About.tsx
    

    我们简单添加下背景色、写下跳转逻辑即可,代码如下

    1. Home.tsx
    import { Link } from 'react-router-dom';
    
    
    
    export default function Home() {
    
      return (
        <div
          style={{
            backgroundColor: 'blueviolet',
            color: '#fff',
          }}
        >
          {' '
            .repeat(100)
            .split('')
            .map((txt, idx) => (
              <div key={idx}>
                <Link to="/about">to About</Link>
              </div>
            ))}
        </div>
      );
    }
    
    1. About.tsx
    import { useNavigate } from 'react-router-dom';
    
    
    
    export default function Home() {
    
      const navigate = useNavigate();
    
      const goBack = () => {
        navigate(-1);
      };
    
      return (
        <div
          style={{
            backgroundColor: 'darkblue',
            color: '#fff',
          }}
        >
          {' '
            .repeat(100)
            .split('')
            .map((txt, idx) => (
              <div key={idx}>
                <a onClick={goBack}>back to home</a>
              </div>
            ))}
        </div>
      );
    }
    

创建路由文件

touch routes.tsx routes-render.tsx
  1. routes-render.tsx

    这是对路由的基本封装,主要是animation layout容器添加(后续也可以在这里添加路由keeplive的逻辑,或者一些其他功能), 这样我们只需要在src/routes中添加路由会变得简单快捷。

    通过修改routerAnimationMode变量调整动画方式

    import {
    
      createHashRouter,
      Navigate,
      NonIndexRouteObject,
      Outlet,
      RouterProvider,
    } from 'react-router-dom';
    import { routes } from './routes';
    import LayoutContainer from './layout/Layout';
    import { IRouterAnimationMode } from './layout/FramerVariants';
    
    
    export const ROOTPATH = '/home';
    
    /** 路由过场动画模式, 这里可以调整动画方式 */
    const routerAnimationMode: IRouterAnimationMode = 'slide';
    
    
    export function AppContainer() {
      return <RouterProvider router={router} />;
    }
    
    // eslint-disable-next-line react-refresh/only-export-components
    export const router = createHashRouter([
      {
        path: '/',
        element: <Outlet />,
        children: [
          {
            path: '/',
            element: <Navigate replace to={ROOTPATH} />,
          },
          ...getRoutes(),
        ],
      },
    ] as Array<NonIndexRouteObject>);
    
    function getRoutes() {
      return routes.map(({ component: Component, ...otherOptions }) => {
        const { path, caseSensitive, index, element, ...restProps } = otherOptions;
    
        return {
          path: '/',
          element: <LayoutContainer routerAnimationMode={routerAnimationMode} />,
          children: [
            {
              path,
              id: path,
              caseSensitive: caseSensitive || true,
              index: index || path === ROOTPATH,
              element: <Component />,
              ...restProps,
            },
          ],
        } as NonIndexRouteObject;
      });
    }
    
    1. routes.tsx

    这里就是路由注册的地方,routes的配置和react-router’s data-routes一致

    TIPS: 排除了children属性。 如果需要,可以将`routes-render -> getRoutes函数递归处理一下即可

    import Home from './pages/Home';
    import About from './pages/About';
    import { NonIndexRouteObject } from 'react-router';
    
    
    
    // eslint-disable-next-line react-refresh/only-export-components
    export const routes: Array<IRoute> = [
      {
        path: '/home',
        component: Home,
      },
      {
        path: '/about',
        component: About,
      },
    ];
    
    
    /**
     * route节点类型
     *
     * @description 排除了children, 如果需要,可以将`routes-render -> getRoutes函数递归处理一下即可`
     */
    export interface IRoute
      extends Omit<NonIndexRouteObject, 'children' | 'element'> {
      /** 组件 */
      component: () => React.ReactNode;
    }
    

创建动画layout,以及配置

  • 创建src/layout文件夹, src/layout/Layout.tsx, src/layout/FramerVariants.ts

    先模拟 slide/fade/none 路由动画 (none表示无动画)

    mkdir src/layout && touch src/layout/FramerVariants.ts src/layout/Layout.tsx
    
    1. FramerVariants.ts

    通过传入 ‘slide’ | ‘fade’ | ‘none’ 获取动画配置

    其中 RouteTransition变量 可控制动画的表现行为。

    具体参数参见:framer-motion/dist/index.d.ts 搜索 interface Spring查看具体配置。

    import { Transition, Variants } from 'framer-motion';
    
    
    
    export type IRouterAnimationMode = 'fade' | 'slide' | 'none';
    
    
    
    export interface IRouteAnimation {
      /**
       * 动画模式 -> "fade" | "slide" | "none"
       * @default 'slide'
       */
      routerAnimationMode?: IRouterAnimationMode;
    }
    
    // framer-motion/dist/index.d.ts 搜索 `interface Spring`查看具体配置,调整动画行为
    const RouteTransition: Transition = {
      type: 'spring',
      duration: 0.5,
    };
    
    const RouteFadeTransition: Transition = {
      type: 'spring',
      duration: 1.6,
    };
    
    
    function getTransition(mode: IRouterAnimationMode) {
      if (mode === 'fade') {
        return {
          PushVariants: {
            initial: {
              opacity: 0,
            },
            in: {
              opacity: 1,
            },
            out: {
              opacity: 0,
            },
          },
          PopVariants: {
            initial: {
              opacity: 0,
            },
            in: {
              opacity: 1,
            },
            out: {
              opacity: 0,
            },
          },
        };
      }
      if (mode === 'slide') {
        return {
          PushVariants: {
            initial: {
              opacity: 0,
              x: '100vw',
            },
            in: {
              opacity: 1,
              x: 0,
            },
            out: {
              opacity: 0,
              x: '-100vw',
            },
          },
          PopVariants: {
            initial: {
              opacity: 0,
              x: '-100vw',
            },
            in: {
              opacity: 1,
              x: 0,
            },
            out: {
              opacity: 0,
              x: '100vw',
            },
          },
        };
      }
      return {} as Variants;
    }
    
    function getRouteTransition(mode: IRouterAnimationMode) {
      if (mode === 'fade') {
        return RouteFadeTransition;
      }
      if (mode === 'slide') {
        return RouteTransition;
      }
      return {} as Transition;
    }
    
    export { getRouteTransition, getTransition };
    
    
    1. Layout.tsx

    动画容器,作为动画的承载容器(需要被motion.div包裹)

    useNavigationType函数可以获得当前action (Push/Pop/Replace)

    import {
    
      IRouteAnimation,
      getRouteTransition,
      getTransition,
    } from './FramerVariants';
    import { HTMLAttributes } from 'react';
    import { IRoute } from '../routes';
    import { motion } from 'framer-motion';
    import { Outlet, useLocation, useNavigationType } from 'react-router-dom';
    
    // eslint-disable-next-line react-refresh/only-export-components
    export enum Action {
      Pop = 'POP',
      Push = 'PUSH',
      Replace = 'REPLACE',
    }
    
    interface IPageProp extends HTMLAttributes<JSX.Element> {
      /**
       * 是否开启路由过场动画
       *
       * @default true
       */
      enableRouteAnimation?: boolean;
    }
    
    interface IPageProp extends HTMLAttributes<JSX.Element>, IRouteAnimation {
      action?: Action;
      options?: IRoute;
    }
    
    export default function Layout({ routerAnimationMode = 'slide' }: IPageProp) {
      const location = useLocation();
      const action: Action = useNavigationType();
      const { PushVariants, PopVariants } = getTransition(routerAnimationMode);
      const RouteTransition = getRouteTransition(routerAnimationMode);
    
      return (
        <motion.div
          animate="in"
          className="motion-wrapper"
          exit="out"
          initial="initial"
          key={location.pathname}
          transition={RouteTransition}
          variants={action === Action.Push ? PushVariants : PopVariants}
        >
          <div className="layout-container">
            <Outlet />
          </div>
        </motion.div>
      );
    }
    

修改main.tx

import { createRoot } from 'react-dom/client';
import { AppContainer } from './routes-render';
import './index.scss';



createRoot(document.getElementById('root')!).render(<AppContainer />);

修改 index.scss

body,
html,
#root {
  font-family: -apple-system, BlinkMacSystemFont, Roboto, 'Segoe UI',
    'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Source Han Sans SC',
    'Mi Lan Pro VF', 'Mi Lanting', 'Noto Sans CJK SC', 'Microsoft YaHei',
    'Heiti SC', DengXian, sans-serif;
  padding: 0;
  margin: 0;
  min-width: 100%;


  a {
    display: inline-block;
    text-decoration: underline !important;
    color: #fff;
    margin-bottom: 26px;
  }
}

html {
  touch-action: manipulation;
}


.motion-wrapper {
  width: 100%;
  height: 100vh;
  overflow: hidden;
  position: absolute;
}

.layout-container {
  position: relative;
  height: 100vh;
  overflow-y: auto;
  overflow-x: hidden;
}

启动,浏览器查看效果

npm run dev

oneline demo

gif的效果有点差

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYXLIncP' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片