React Router 过渡进出事件

React Router transition in and out events

我对我正在使用的小型网站进行了相当基本的设置。我正在使用 React 和 React Router 4。现在我想在用户进入路线时添加过渡,以使用一些 javascript 动画过渡 IN 和 OUT 该路线。但是,我不知道如何正确执行此操作?假设用户在 / 并单击导航到 /projects/one 的 link,那么我如何才能为此开始过渡 IN,如果用户导航离开以开始过渡 OUT 对于那个 component/route?我不希望东西只是 "unmount",我希望它们在转换之间平滑并具有控制权..? 超时值只是一个示例时间。

目前我有以下内容:

更新:

基于 Ryan C 代码示例,我已经能够想出一个非常接近我想要的解决方案,因此删除了我的旧代码,因为它离我的太远了最初的问题。

代码:https://codesandbox.io/s/k2r02r378o

对于当前版本,我目前有两个问题无法弄清楚...

  1. 如果用户当前位于主页 (/),并且用户点击了相同路径的 Link,我该如何防止我的过渡流程发生,只是一种没做什么?同时不在浏览器中添加大量具有相同路径的历史记录?

  2. 如果用户在主页 (/) 并导航到项目页面 (/projects/one),并且在转换完成之前用户再次导航回主页 (/),那么我会就像主页的 "transitionOut" 停在它所在的位置,然后再次 运行 "transitionIn"(有点倒回我的过渡)..也许它连接到 1)?

我保留了这个答案,以便评论仍然有意义并且可以看到演变,但这已被 [=42 取代=]

这里有一些相关的参考资料,我希望你已经看过其中的一些:

https://reacttraining.com/react-router/web/api/Route/children-func

https://reactcommunity.org/react-transition-group/transition

https://greensock.com/react

code sandbox加上下面的代码,可以快速看到效果

下面的代码使用 Transition within a Route 使用 addEndListener 属性 使用 gsap 插入自定义动画。完成这项工作有几个重要方面。 Transition 要经过 entering 状态,in 属性 必须从 falsetrue。如果它从 true 开始,那么它会立即跳到 entered 状态而不进行转换。为了在 Route 内发生这种情况,您需要使用路线的 children 属性(而不是 componentrender),因为那时无论 Route 是否匹配,children 都会被渲染。在下面的示例中,您将看到:

<Route exact path="/projects/one">
    {({ match }) => <Projects show={match !== null} />}
</Route>

这会将一个布尔值 show 属性 传递给组件,只有在路由匹配时才会为真。这将作为 Transitionin 属性 传递。这允许 Projectsin={false} 开始,而不是(当使用 Route component 属性 时)开始时根本没有渲染(这会阻止转换发生,因为它然后在第一次渲染时会有 in={true})。

我没有完全理解您在 componentDidMount 项目中尝试做的所有事情(我的示例已大大简化,但确实做了一个多步 gsap 动画),但我想您会的最好使用 Transition 来控制所有动画的触发,而不是尝试同时使用 TransitioncomponentDidMount.

这是第一个代码版本:

import React from "react";
import ReactDOM from "react-dom";
import { Transition } from "react-transition-group";
import { BrowserRouter, Route, Link } from "react-router-dom";
import { TweenLite, TimelineMax } from "gsap";

const startState = { autoAlpha: 0, y: -50 };
const onEnter = node => TweenLite.set(node, startState);
const addEndListener = props => (node, done) => {
  const timeline = new TimelineMax();
  if (props.show) {
    timeline
      .to(node, 0.5, {
        autoAlpha: 1,
        y: 0
      })
      .to(node, 0.5, { x: -25 })
      .to(node, 0.5, {
        x: 0,
        onComplete: done
      });
  } else {
    timeline.to(node, 0.5, {
      autoAlpha: 0,
      y: 50,
      onComplete: done
    });
  }
};
const Home = props => {
  return (
    <Transition
      unmountOnExit
      in={props.show}
      onEnter={onEnter}
      addEndListener={addEndListener(props)}
    >
      {state => {
        return <div>Hello {state + " Home!"}</div>;
      }}
    </Transition>
  );
};
const Projects = props => {
  return (
    <Transition
      unmountOnExit
      in={props.show}
      onEnter={onEnter}
      addEndListener={addEndListener(props)}
    >
      {state => {
        return <div>Hello {state + " Projects!"}</div>;
      }}
    </Transition>
  );
};

const App = props => {
  return (
    <BrowserRouter>
      <div>
        <br />
        <Link to="/">Home</Link>
        <br />
        <Link to="/projects/one">Show project</Link>
        <br />
        <Route exact path="/">
          {({ match }) => <Home show={match !== null} />}
        </Route>
        <Route exact path="/projects/one">
          {({ match }) => <Projects show={match !== null} />}
        </Route>
      </div>
    </BrowserRouter>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

更新 1: 解决更新中的问题 #1。 react-router 版本 4 的一个好处是路由可以出现在多个地方并控制页面的多个部分。在此 code sandbox 中,我更新了您的代码沙箱,让主页 link 在 Link 和静态文本之间切换(尽管您可以更改它以使用样式,这样两者的外观相同)。我用 LinkOrStaticText 替换了 Link(我很快就做了这个,它可以使用一些改进来更稳健地处理传递的道具):

const LinkOrStatic = props => {
  const path = props.to;
  return (
    <Route exact path={path}>
      {({ match }) => {
        if (match) {
          return props.children;
        }
        return (
          <Link className={props.className} to={props.to}>
            {props.children}
          </Link>
        );
      }}
    </Route>
  );
};

我将单独更新以解决问题 2。

更新 2:在尝试解决问题 2 时,我发现我在此答案中使用的方法存在一些基本问题。由于在某些情况下同时执行多个路由以及正在进行的未安装转换的奇怪残余问题,行为变得混乱。我需要使用不同的方法从头开始,所以我将修改后的方法发布在一个单独的答案中。

所以事实证明,如果您从路线 1 切换到路线 2,然后在路线 1 仍在退出时返回路线 1,则支持重新启动进入转换的方法非常棘手。我的东西可能有一些小问题,但我认为整体方法是合理的。

总体方法涉及从渲染路径(当前显示的可能处于过渡状态的路径)中分离出目标路径(用户想要去的地方)。为了确保过渡在适当的时间发生,状态用于逐步对事物进行排序(例如,首先使用 in=false 渲染过渡,然后使用 in=true 渲染进入过渡)。大部分复杂性在 TransitionManager.js.

内处理

我在我的代码中使用了钩子,因为它更容易处理逻辑,而没有 类 的语法开销,所以在接下来的几个月左右,这将只适用于α。如果正式版本中的 hooks 实现以任何方式更改并破坏此代码,我将在那时更新此答案。

代码如下:

index.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

App.js

import React from "react";
import { BrowserRouter } from "react-router-dom";
import LinkOrStatic from "./LinkOrStatic";
import { componentInfoArray } from "./components";
import {
  useTransitionContextState,
  TransitionContext
} from "./TransitionContext";
import TransitionRoute from "./TransitionRoute";

const App = props => {
  const transitionContext = useTransitionContextState();
  return (
    <TransitionContext.Provider value={transitionContext}>
      <BrowserRouter>
        <div>
          <br />
          {componentInfoArray.map(compInfo => (
            <LinkOrStatic key={compInfo.path} to={compInfo.path}>
              {compInfo.linkText}
            </LinkOrStatic>
          ))}

          {componentInfoArray.map(compInfo => (
            <TransitionRoute
              key={compInfo.path}
              path={compInfo.path}
              exact
              component={compInfo.component}
            />
          ))}
        </div>
      </BrowserRouter>
    </TransitionContext.Provider>
  );
};
export default App;

TransitionContext.js

import React, { useState } from "react";

export const TransitionContext = React.createContext();
export const useTransitionContextState = () => {
  // The path most recently requested by the user
  const [targetPath, setTargetPath] = useState(null);
  // The path currently rendered. If different than the target path,
  // then probably in the middle of a transition.
  const [renderInfo, setRenderInfo] = useState(null);
  const [exitTimelineAndDone, setExitTimelineAndDone] = useState({});
  const transitionContext = {
    targetPath,
    setTargetPath,
    renderInfo,
    setRenderInfo,
    exitTimelineAndDone,
    setExitTimelineAndDone
  };
  return transitionContext;
};

components.js

import React from "react";
const Home = props => {
  return <div>Hello {props.state + " Home!"}</div>;
};
const ProjectOne = props => {
  return <div>Hello {props.state + " Project One!"}</div>;
};
const ProjectTwo = props => {
  return <div>Hello {props.state + " Project Two!"}</div>;
};
export const componentInfoArray = [
  {
    linkText: "Home",
    component: Home,
    path: "/"
  },
  {
    linkText: "Show project one",
    component: ProjectOne,
    path: "/projects/one"
  },
  {
    linkText: "Show project two",
    component: ProjectTwo,
    path: "/projects/two"
  }
];

LinkOrStatic.js

import React from "react";
import { Route, Link } from "react-router-dom";

const LinkOrStatic = props => {
  const path = props.to;
  return (
    <>
      <Route exact path={path}>
        {({ match }) => {
          if (match) {
            return props.children;
          }
          return (
            <Link className={props.className} to={props.to}>
              {props.children}
            </Link>
          );
        }}
      </Route>
      <br />
    </>
  );
};
export default LinkOrStatic;

TransitionRoute.js

import React from "react";
import { Route } from "react-router-dom";
import TransitionManager from "./TransitionManager";

const TransitionRoute = props => {
  return (
    <Route path={props.path} exact>
      {({ match }) => {
        return (
          <TransitionManager
            key={props.path}
            path={props.path}
            component={props.component}
            match={match}
          />
        );
      }}
    </Route>
  );
};
export default TransitionRoute;

TransitionManager.js

import React, { useContext, useEffect } from "react";
import { Transition } from "react-transition-group";
import {
  slowFadeInAndDropFromAboveThenLeftRight,
  slowFadeOutAndDrop
} from "./animations";
import { TransitionContext } from "./TransitionContext";

const NEW_TARGET = "NEW_TARGET";
const NEW_TARGET_MATCHES_EXITING_PATH = "NEW_TARGET_MATCHES_EXITING_PATH";
const FIRST_TARGET_NOT_RENDERED = "FIRST_TARGET_NOT_RENDERED";
const TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED =
  "TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED";
const TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING =
  "TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING";
const TARGET_RENDERED = "TARGET_RENDERED";
const NOT_TARGET_AND_NEED_TO_START_EXITING =
  "NOT_TARGET_AND_NEED_TO_START_EXITING";
const NOT_TARGET_AND_EXITING = "NOT_TARGET_AND_EXITING";
const NOT_TARGET = "NOT_TARGET";
const usePathTransitionCase = (path, match) => {
  const {
    targetPath,
    setTargetPath,
    renderInfo,
    setRenderInfo,
    exitTimelineAndDone,
    setExitTimelineAndDone
  } = useContext(TransitionContext);
  let pathTransitionCase = null;
  if (match) {
    if (targetPath !== path) {
      if (
        renderInfo &&
        renderInfo.path === path &&
        renderInfo.transitionState === "exiting" &&
        exitTimelineAndDone.timeline
      ) {
        pathTransitionCase = NEW_TARGET_MATCHES_EXITING_PATH;
      } else {
        pathTransitionCase = NEW_TARGET;
      }
    } else if (renderInfo === null) {
      pathTransitionCase = FIRST_TARGET_NOT_RENDERED;
    } else if (renderInfo.path !== path) {
      if (renderInfo.transitionState === "exited") {
        pathTransitionCase = TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED;
      } else {
        pathTransitionCase = TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING;
      }
    } else {
      pathTransitionCase = TARGET_RENDERED;
    }
  } else {
    if (renderInfo !== null && renderInfo.path === path) {
      if (
        renderInfo.transitionState !== "exiting" &&
        renderInfo.transitionState !== "exited"
      ) {
        pathTransitionCase = NOT_TARGET_AND_NEED_TO_START_EXITING;
      } else {
        pathTransitionCase = NOT_TARGET_AND_EXITING;
      }
    } else {
      pathTransitionCase = NOT_TARGET;
    }
  }
  useEffect(() => {
    switch (pathTransitionCase) {
      case NEW_TARGET_MATCHES_EXITING_PATH:
        exitTimelineAndDone.timeline.kill();
        exitTimelineAndDone.done();
        setExitTimelineAndDone({});
        // Making it look like we exited some other path, in
        // order to restart the transition into this path.
        setRenderInfo({
          path: path + "-exited",
          transitionState: "exited"
        });
        setTargetPath(path);
        break;
      case NEW_TARGET:
        setTargetPath(path);
        break;
      case FIRST_TARGET_NOT_RENDERED:
        setRenderInfo({ path: path });
        break;
      case TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED:
        setRenderInfo({ path: path, transitionState: "entering" });
        break;
      case NOT_TARGET_AND_NEED_TO_START_EXITING:
        setRenderInfo({ ...renderInfo, transitionState: "exiting" });
        break;
      // case TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING:
      // case NOT_TARGET:
      default:
      // no-op
    }
  });
  return {
    renderInfo,
    setRenderInfo,
    setExitTimelineAndDone,
    pathTransitionCase
  };
};

const TransitionManager = props => {
  const {
    renderInfo,
    setRenderInfo,
    setExitTimelineAndDone,
    pathTransitionCase
  } = usePathTransitionCase(props.path, props.match);
  const getEnterTransition = show => (
    <Transition
      key={props.path}
      addEndListener={slowFadeInAndDropFromAboveThenLeftRight()}
      in={show}
      unmountOnExit={true}
    >
      {state => {
        const Child = props.component;
        console.log(props.path + ": " + state);
        return <Child state={state} />;
      }}
    </Transition>
  );
  const getExitTransition = () => {
    return (
      <Transition
        key={props.path}
        addEndListener={slowFadeOutAndDrop(setExitTimelineAndDone)}
        in={false}
        onExited={() =>
          setRenderInfo({ ...renderInfo, transitionState: "exited" })
        }
        unmountOnExit={true}
      >
        {state => {
          const Child = props.component;
          console.log(props.path + ": " + state);
          return <Child state={state} />;
        }}
      </Transition>
    );
  };
  switch (pathTransitionCase) {
    case NEW_TARGET_MATCHES_EXITING_PATH:
    case NEW_TARGET:
    case FIRST_TARGET_NOT_RENDERED:
    case TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING:
      return null;
    case TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED:
      return getEnterTransition(false);
    case TARGET_RENDERED:
      return getEnterTransition(true);
    case NOT_TARGET_AND_NEED_TO_START_EXITING:
    case NOT_TARGET_AND_EXITING:
      return getExitTransition();
    // case NOT_TARGET:
    default:
      return null;
  }
};
export default TransitionManager;

animations.js

import { TimelineMax } from "gsap";
const startStyle = { autoAlpha: 0, y: -50 };
export const slowFadeInAndDropFromAboveThenLeftRight = trackTimelineAndDone => (
  node,
  done
) => {
  const timeline = new TimelineMax();
  if (trackTimelineAndDone) {
    trackTimelineAndDone({ timeline, done });
  }
  timeline.set(node, startStyle);
  timeline
    .to(node, 0.5, {
      autoAlpha: 1,
      y: 0
    })
    .to(node, 0.5, { x: -25 })
    .to(node, 0.5, {
      x: 0,
      onComplete: done
    });
};
export const slowFadeOutAndDrop = trackTimelineAndDone => (node, done) => {
  const timeline = new TimelineMax();
  if (trackTimelineAndDone) {
    trackTimelineAndDone({ timeline, done });
  }
  timeline.to(node, 2, {
    autoAlpha: 0,
    y: 100,
    onComplete: done
  });
};