GSAP + React, Advanced Animation Techniques

有时候我们需要在不同的组件中分享同一个时间线动画,或者把不同组件中的动画统一进行组织调度。为了达到这个目的,我们需要了解如何在不同的组件当中进行通信。

组件通信

Component Communication

有两种基本的方式可以实现

  1. 利用props通过父元素给子元素传递数据,比如传递一个时间线实例

  1. 还是利用props传递数据,但是是传递一个回调函数给子元素去调用,调用这个函数可以给时间线动画添加动画

传递一个时间线实例

传递一个回调函数可以给时间线添加动画

父元素

父元素

子元素

子元素

添加动画

时间线实例

回调函数

传递timeline实例

Passing down a timeline prop

function Box({ children, timeline, index }) {
  const el = useRef();
  //  添加 向左移动100的动画
  useLayoutEffect(() => {    
    timeline && timeline.to(el.current, { x: -100 }, index * 0.1);
  }, [timeline]);
  
  return <div className="box" ref={el}>{children}</div>;
}

function Circle({ children, timeline, index, rotation }) {
  const el = useRef();
  
  useLayoutEffect(() => {   
    // 添加向右移动100,旋转360度的动画
    timeline && timeline.to(el.current, {  rotate: rotation, x: 100 }, index * 0.1);
  }, [timeline, rotation]);
  
  return <div className="circle" ref={el}>{children}</div>;
}

function App() {    
  const [reversed, setReversed] = useState(false);
  const [tl, setTl] = useState();
  
  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
      const tl = gsap.timeline();
   // 把时间线实例添加进tl变量中
      setTl(tl);
    });
    return () => ctx.revert();
  }, []);
  
  useLayoutEffect(() => {
    tl && tl.reversed(reversed);
  }, [reversed, tl]);
     
  return (
    <div className="app">   
      <button onClick={() => setReversed(!reversed)}>Toggle</button>
      <Box timeline={tl} index={0}>Box</Box>
      <Circle timeline={tl} rotation={360} index={1}>Circle</Circle>
    </div>
  );
}

注意下面我们使用了useState替代了useRef来引用时间线实例。这样能保证时间线实例在子元素中渲染完成后第一时间可用。

传递一个回调函数用来给时间线添加动画

Passing down a callback to build a timeline

function Box({ children, addAnimation, index }) {
  const el = useRef();
  
  useLayoutEffect(() => {
    const animation = gsap.to(el.current, { x: -100 });
    addAnimation(animation, index);
    
    return () => animation.progress(0).kill();
  }, [addAnimation, index]);
  
  return <div className="box" ref={el}>{children}</div>;
}

function Circle({ children, addAnimation, index, rotation }) {
  const el = useRef();
  
  useLayoutEffect(() => {
    const animation = gsap.to(el.current, { rotate: rotation, x: 100 });
    addAnimation(animation, index);
    
    return () => animation.progress(0).kill();
  }, [addAnimation, index, rotation]);
  
  return <div className="circle" ref={el}>{children}</div>;
}


function App() {
  // 定义一个时间线实例
  const [tl, setTl] = useState();
  // 给子元素传递这个函数,通过这个函数给时间线实例添加动画
  const addAnimation = useCallback((animation, index) => {    
    tl.add(animation, index * 0.1);
  }, [tl]);
     
  return (
    <div className="app">   
      <button onClick={() => setReversed(!reversed)}>Toggle</button>
      <Box addAnimation={addAnimation} index={0}>Box</Box>
      <Circle addAnimation={addAnimation} index={1} rotation="360">Circle</Circle>
    </div>
  );
}

React上下文

React Context

给子元素传递属性或者回调函数对于一些情况来说并不是很好的解决方案。

如果你想要进行通信的组件被嵌套在很多层的结构当中给,或者根本就是在另一个结构中。像这样的一些情况,就就可以使用ReactContext

这样的话,不管你想要通信的组件在哪里,只要你使用了useContext这个hook,那么在这个组件当中你就能获取到传递的数据。

const SelectedContext = createContext();

function Box({ children, id }) {  
  const el = useRef();
  const { selected } = useContext(SelectedContext);
  const ctx = gsap.context(() => {});
  
  useLayoutEffect(() => {
    return () => ctx.revert(); 
  }, []);
  
  useLayoutEffect(() => {
    ctx.add(() => {
      gsap.to(el.current, {
        x: selected === id ? 200 : 0
      });
    });
  }, [selected, id]);
  
  return <div className="box" ref={el}>{children}</div>;
}

function Boxes() {
  return (
    <div className="boxes">
      <Box id="1">Box 1</Box>
      <Box id="2">Box 2</Box>
      <Box id="3">Box 3</Box>
    </div>  
  );  
}

function Menu() {
  
  const { selected, setSelected } = useContext(SelectedContext);
  
  const onChange = (e) => {
    setSelected(e.target.value);
  };
  
  return (
    <div className="menu">      
      <label>
        <input 
          onChange={onChange} 
          checked={selected === "1"}
          type="radio"             
          value="1" 
          name="selcted"/> Box 1
      </label>    
      <label>
        <input 
          onChange={onChange} 
          checked={selected === "2"}
          type="radio"             
          value="2" 
          name="selcted"/> Box 2
      </label>  
      <label>
        <input 
          onChange={onChange} 
          checked={selected === "3"}
          type="radio"             
          value="3" 
          name="selcted"/> Box 3
      </label>  
    </div>    
  );
}

function App() {    
  const [selected, setSelected] = useState("2");
     
  return (
    <div className="app">   
      <SelectedContext.Provider value={{ selected, setSelected }}>    
        <Menu />
        <Boxes />
      </SelectedContext.Provider>
    </div>
  );
}

持续变化通信

Imperative Communication

传递参数或者是使用context在绝大部分情况下是能够解决各种问题的,但是这两个方式会导致重复渲染,如果你的变化是一种持续的变化,可能会影响到应用的性能表现,比如你的变化是基于鼠标的坐标点的。

为了绕过React的渲染阶段,我们可以使用useImperativeHandle这个hook,为我们的组件创建一个API

const Circle = forwardRef((props, ref) => {
  const el = useRef();
    
  useImperativeHandle(ref, () => {           
    
    // 返回API
    return {
      moveTo(x, y) {
        gsap.to(el.current, { x, y });
      }
    };
  }, []);
  
  return <div className="circle" ref={el}></div>;
});
function App() {    
  const circleRef = useRef();
       
  useLayoutEffect(() => {    
    // 不引起重新渲染
    circleRef.current.moveTo(300, 100);
  }, []);
    
  return (
    <div className="app">   
      <Circle ref={circleRef} />
    </div>
  );
}

不管useImperativeHandle这个hook返回什么,都会设置到到传入的Ref上。

创建可复用动画

Creating reusable animations

创建可复用的动画是能在你需要减少代码文件尺寸时,让代码保持清爽整洁的一个重要方式。最简单的方式就是利用函数去创建动画。

function fadeIn(target, vars) {
  return gsap.from(target, { opacity: 0, ...vars });
}

function App() {    
  const box = useRef();
    
  useLayoutEffect(() => {
    const animation = fadeIn(box.current, { x: 100 });
  }, []);
  
  return <div className="box" ref={box}>Hello</div>;
}

用一个种声明式的方式,你可以创建一个组件来处理动画。

function FadeIn({ children, vars }) {
  const el = useRef();
  
  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
      animation.current = gsap.from(el.current.children, { 
        opacity: 0,
        ...vars
      });
    });
    return () => ctx.revert();       
  }, []);
  
  return <span ref={el}>{children}</span>;
}
  
function App() {      
  return (
    <FadeIn vars={{ x: 100 }}>
      <div className="box">Box</div>
    </FadeIn>
  );
}

如果你想要使用React Fragment或者是变化一个函数组件,你需要给目标元素传递一个ref

RegisterEffect()

GSAP提供了一种特别的方式去创建可复用的动画——使用registerEffect()

function GsapEffect({ children, targetRef, effect, vars }) {  
  
  useLayoutEffect(() => {
    if (gsap.effects[effect]) {
      ctx.add(() => {
        animation.current = gsap.effects[effect](targetRef.current, vars);
      });
    }
  }, [effect]);
    
  return <>{children}</>;
}

function App() {      
  const box = useRef();
  
  return (
    <GsapEffect targetRef={box} effect="spin">
      <Box ref={box}>Hello</Box>
    </GsapEffect>
  );
}

Exit animations

当要让元素实现类似于从DOM节点上删除的动画效果,我们需要让React延迟移除元素。我们可以在动画结束后再去改变组件的状态来实现这样的效果。

退出动画

function App() {      
  const boxRef = useRef();
  const [active, setActive] = useState(true);  
  const [ctx, setCtx] = useState(gsap.context(() => {}, app));
  
  useLayoutEffect(() => {
    ctx.add("remove", () => {
      gsap.to(ctx.selector(".box"), {
        opacity: 0,
        onComplete: () => setActive(false)
      });
    });
    return () => ctx.revert();
  }, []);
  
  return (
    <div>
      <button onClick={ctx.remove}>Remove</button>
      { active ? <div ref={boxRef}>Box</div> : null }
    </div>
  );
}

这样的方式也可以用在从数组中渲染元素。

function App() {    
  
  const [items, setItems] = useState([
    { id: 0 },
    { id: 1 },
    { id: 2 }
  ]);
  
  const removeItem = (value) => {
    setItems(prev => prev.filter(item => item !== value));
  }
  
  useLayoutEffect(() => {
    ctx.add("remove", (item, target) => {
      gsap.to(target, {
        opacity: 0,
        onComplete: () => removeItem(item)
      });
    });
    return () => ctx.revert();
  }, []);
  
  return (
    <div>
      {items.map((item) => (
        <div key={item.id} onClick={(e) => ctx.remove(item, e.currentTarget)}>
          Click Me
        </div>
      ))}
    </div>
  );
}

你应该看到上面的这个布局变化效果,这种就是典型的元素退出动画。有一个插件flip plugin能够让这个退出的过程更加顺滑。

下面这个案例中,我们使用了Flip插件,通过插件中的onEnteronLeave事件来决定元素的动画表现。为了让元素消失,我们需要通过onLeave事件让元素的display设置为none

Custom Hooks

如果你发现你自己总是重复使用同样的逻辑,那么你最好把这些逻辑封装成自定义的hook,这样的话整个逻辑复用起来会很方便。

下面的代码就是实现了一个registerEffect的自定义Hook

自定义钩子

function useGsapEffect(target, effect, vars) {
  const [animation, setAnimation] = useState();
  
  useLayoutEffect(() => {
    setAnimation(gsap.effects[effect](target.current, vars));    
  }, [effect]);
  
  return animation;
}

function App() {      
  const box = useRef();
  const animation = useGsapEffect(box, "spin");
  
  return <Box ref={box}>Hello</Box>;
}

下面是一些我们实现的一些自定义hook,可能会对你有用:

useGsapContext

GSAPContext实例进行缓存

function useGsapContext(scope) {
  const ctx = useMemo(() => gsap.context(() => {}, scope), [scope]);
  return ctx;
}

使用方式:

function App() {
  const ctx = useGsapContext(ref);
  
  useLayoutEffect(() => {
    ctx.add(() => {
      gsap.to(".box", {
        x: 200,
        stagger: 0.1
      });
    });
    return () => ctx.revert();
  }, []);
  
  return (
    <div className="app" ref={ref}>
      <div className="box">Box 1</div>
      <div className="box">Box 2</div>
      <div className="box">Box 3</div>
    </div>
  );
}

useStateRef

这个hook解决的是在回调中过期数据的值的问题。他用起来很香useState,但是它返回的第三个值,是一个ref,这个ref有当前的state

function useStateRef(defaultValue) {
  const [state, setState] = useState(defaultValue);
  const ref = useRef(state);

  const dispatch = useCallback((value) => {
    ref.current = typeof value === "function" ? value(ref.current) : value;
    setState(ref.current);
  }, []);

  return [state, dispatch, ref];
}

使用方式:

const [count, setCount, countRef] = useStateRef(5);
const [gsapCount, setGsapCount] = useState(0);  

useLayoutEffect(() => {
  const ctx = gsap.context(() => {
    gsap.to(".box", {
      x: 200,
      repeat: -1,
      onRepeat: () => setGsapCount(countRef.current)
    });
  }, app);
  return () => ctx.revert();
}, []);

useIsomorphicLayoutEffect

当你使用SSR(服务器端渲染),如果你在代码中使用了useLayoutEffect你总会看到警告提示。你可以根据情况,让你的代码在服务器端进行渲染的时候使用useEffect。这个hook就是能够帮助你来区别处理,让代码如果在服务器端执行就用useEffect,如果在浏览器端执行就使用useLayoutEffect

重要:当你使用gsapfrom设置的元素的初始状态,和在服务器中渲染出来的页面效果的初始状态不一致,那么元素可能会在js代码解析执行后有瞬间的变化过程会被看到。

const useIsomorphicLayoutEffect = typeof window !== "undefined" 
  ? useLayoutEffect 
  : useEffect;

使用方式:

function App() {    
  const app = useRef();
    
  useIsomorphicLayoutEffect(() => {
    const ctx = gsap.context(() => {
      gsap.from(".box", { opacity: 0 });
    }, app);
    return () => ctx.revert();
  }, []);
  
  return (
    <div className="app" ref={app}>
      <div className="box">Box 1</div>
    </div>
  );
}