GSAP + React, Advanced Animation Techniques
有时候我们需要在不同的组件中分享同一个时间线动画,或者把不同组件中的动画统一进行组织调度。为了达到这个目的,我们需要了解如何在不同的组件当中进行通信。
组件通信
Component Communication
有两种基本的方式可以实现
利用props通过父元素给子元素传递数据,比如传递一个时间线实例
还是利用props传递数据,但是是传递一个回调函数给子元素去调用,调用这个函数可以给时间线动画添加动画
传递timeline实例
Passing down a timeline prop
function Box({ children, timeline, index }) {
const el = useRef();
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(() => {
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();
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
给子元素传递属性或者回调函数对于一些情况来说并不是很好的解决方案。
如果你想要进行通信的组件被嵌套在很多层的结构当中给,或者根本就是在另一个结构中。像这样的一些情况,就就可以使用React的Context。
这样的话,不管你想要通信的组件在哪里,只要你使用了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, () => {
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插件,通过插件中的onEnter和onLeave事件来决定元素的动画表现。为了让元素消失,我们需要通过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
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。
重要:当你使用gsap的from设置的元素的初始状态,和在服务器中渲染出来的页面效果的初始状态不一致,那么元素可能会在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>
);
}