使用 useLayoutEffect 响应页面/布局转换

React page / layout transitions with useLayoutEffect

我定义了以下结构,以便根据路径名在布局之间设置动画转换。

<LayoutTransition pathname={pathname}>
  {pathname.includes('/registration') && <RegistrationLayout />}
  {pathname.includes('/dashboard') && <DashboardLayout />}
</LayoutTransition>

RegistrationLayoutDashboardLayout 内部结构相似,但它们根据路径名而不是布局显示不同的页面。

在我的 LayoutTransition 组件中,我有以下逻辑

  useLayoutEffect(() => {
    // Root paths are "/registration" "/dashboard"

    const rootPathname = pathname.split('/')[1];
    const rootPrevPathname = prevPathname.current.split('/')[1];

    if (rootPathname !== rootPrevPathname) {
      /*
       * Logic that:
       * 1. Animates component to 0 opacity
       * 2. Sets activeChildren to null
       * 3. Animates component to 1 opacity
       */
    }

    return () => {
      // Sets activeChildren to current one
      setActiveChildren(children);
    };
  }, [pathname]);

  /* renders activeChildren || children */

一般来说,这个概念是有效的,即我在动画 out 时看到我的 "current" children 然后当 activeChildren 设置为空时,我看到我的 "new" children 在 .

中设置动画时

唯一的问题是,当我设置 setActiveChildren(children); 布局 re-renders 时,我看到布局正在显示 returns 到其初始状态的闪烁和页面。

有没有办法避免这种情况并在我们制作动画时有点冻结 children,所以它们不会发生 re-render?

编辑:来自 react-native 项目的完整代码片段

核心思想是我们订阅路由器上下文,当 rootPathname 更改时,我们将当前布局 (children) 动画化,然后将新布局动画化。

import React, { useContext, useLayoutEffect, useRef, useState } from 'react';
import { Animated } from 'react-native';
import RouterCtx from '../context/RouterCtx';
import useAnimated from '../hooks/useAnimated';
import { durationSlow, easeInQuad } from '../services/Animation';

/**
 * Types
 */
interface IProps {
  children: React.ReactNode;
}

/**
 * Component
 */
function AnimRouteLayout({ children }: IProps) {
  const { routerState } = useContext(RouterCtx);
  const { rootPathname } = routerState;
  const [activeChildren, setActiveChildren] = useState<React.ReactNode>(undefined);
  const [pointerEvents, setPointerEvents] = useState(true);
  const prevRootPathname = useRef<string | undefined>(undefined);
  const [animatedValue, startAnimation] = useAnimated(1, {
    duration: durationSlow,
    easing: easeInQuad,
    useNativeDriver: true
  });

  function animationLogic(finished: boolean, value: number) {
    setPointerEvents(false);
    if (finished) {
      if (value === 0) {
        setActiveChildren(undefined);
        startAnimation(1, animationLogic, { delay: 150 });
      }
      setPointerEvents(true);
    }
  }

  useLayoutEffect(() => {
    if (prevRootPathname.current) {
      if (rootPathname !== prevRootPathname.current) {
        startAnimation(0, animationLogic);
      }
    }

    return () => {
      prevRootPathname.current = rootPathname;
      setActiveChildren(children);
    };
  }, [rootPathname]);

  return (
    <Animated.View
      pointerEvents={pointerEvents ? 'auto' : 'none'}
      style={{
        flex: 1,
        opacity: animatedValue.interpolate({ inputRange: [0, 1], outputRange: [0, 1] }),
        transform: [
          {
            scale: animatedValue.interpolate({ inputRange: [0, 1], outputRange: [1.1, 1] })
          }
        ]
      }}
    >
      {activeChildren || children}
    </Animated.View>
  );
}

export default AnimRouteLayout;

首先,我将通过列出当用户从“/registration”开始然后切换到“/dashboard”时发生的步骤来描述我认为您当前的代码正在发生的事情:

  • AnimRouteLayoutrootPathname='/registration' 的初始渲染
    • 初始布局效果已排队
    • activeChildren未定义,所以返回children渲染
    • <RegistrationLayout /> 被渲染
    • 排队的布局效果执行
      • prevRootPathname.current 未定义,所以没有动画
      • 布局效果清理已注册 React
  • 用户切换到“/dashboard”触发 AnimRouteLayoutrootPathname='/dashboard' 的渲染
    • 由于rootPathname不一样,排第二个布局效果
    • activeChildren仍未定义,所以返回children渲染
    • <RegistrationLayout /> 卸载并呈现 <DashboardLayout />
    • 执行上一个布局效果的清理
      • prevRootPathname.current 设置为“/registration”
      • activeChildren 设置为之前的子项导致另一个渲染排队
    • 排队的布局效果执行并开始动画
    • AnimRouteLayout 的另一个渲染由于 activeChildren 状态变化而开始
    • 额外的布局效果排队,因为rootPathname没有不同
    • activeChildren返回渲染
    • <DashboardLayout /> 卸载
    • <RegistrationLayout /> 以新状态重新安装并呈现
    • 动画完成并将 activeChildren 设置回未定义
    • AnimRouteLayout 再次渲染,这次 <DashboardLayout /> 将被渲染

虽然可以通过防止重新安装的方式来管理 activeChildren,但我认为有一种更简洁的方法可以解决此问题。与其试图冻结 children,我认为你最好冻结 pathname。在编写 时,我对这些想法进行了大量试验。为了保持这一点,我想出的术语是区分:

  • targetPathname 用户指定的路径
  • renderPathname当前正在渲染的路径

大多数时候这些路径是相同的。例外情况是在退出转换期间 renderPathname 将具有前一个 targetPathname 的值。使用这种方法,您将得到如下内容:

<AnimRouteLayout targetPathname={pathname}>
  {(renderPathname)=> {
    return <>
      {renderPathname.includes('/registration') && <RegistrationLayout />}
      {renderPathname.includes('/dashboard') && <DashboardLayout />}
    </>;
  }}
</AnimRouteLayout>

然后AnimRouteLayout只需要适当地管理renderPathname

function AnimRouteLayout({ children, targetPathname }) {
  const [renderPathname, setRenderPathname] = useState(targetPathname);
  // End of animation would set renderPathname to targetPathname
  return children(renderPathname);
}

由于我没有尝试为此做一个工作示例,所以我不能保证我在上面没有语法错误,但我相当有信心这种方法是合理的。