mediamonks / transition-component

Add GreenSock animations to your components!
https://mediamonks.github.io/transition-component/
MIT License
11 stars 3 forks source link

Add `useAnimationState` hook #134

Open leroykorterink opened 1 year ago

leroykorterink commented 1 year ago

It's currently not possible to use animation updates inside a component. You need to workaround it using the useExposeAnimation and useExposedAnimation hooks.

const isDesktop = useMediaQuery(...)
const myAnimation = useAnimation(() => createAnimation(refs, isDesktop), [isDesktop])

// Hacky workaround
useExposeAnimation(ref, myAnimation);
const myAnimationState = useExposedAnimation(ref, myAnimation);

Solution 1

Create a hook that uses the workaround solution.

function useAnimationState(animation: RefObject<gsap.core.Animation>): gsap.core.Animation | undefined {
  const ref = useRef(Symbol("useAnimationState"));
  useExposeAnimation(animation, ref);
  return useExposedAnimation(ref);
}

Solution 2

Create an animation hook that uses state to store the animation instance.

function useAnimationState<T extends gsap.core.Animation>(
  callback: () => T | undefined,
  dependencies: ReadonlyArray<unknown>,
): T | undefined {
  const [animation, setAnimation] = useState<T>();

  // eslint-disable-next-line react-hooks/exhaustive-deps
  const _callback = useCallback(callback, dependencies);

  useEffect(() => {
    const _animation = _callback();

    setAnimation(_animation)

    return () => {
      _animation?.kill();
    };
  }, [_callback]);

  return animation;
}

If we use this approach we also need to update the useExposeAnimation hook so that it accepts RefObjects<gsap.core.Animation> and gsap.core.Animation.

ThaNarie commented 1 year ago

For completeness, there is a third solution, but this is error prone, as you would need to keep track of your dependencies and keep them in sync. Also, it might be more complicated when passing things around in hooks.

useEffect(() => {
  // myAnimation.current has changed

  // use the same deps as your animation
}, [isDesktop]);

For solution 2, you would end up with two similar hooks, one returning a ref, and the other returning a "state"?

const myAnimationRef = useAnimation(() => createAnimation(refs, isDesktop), [isDesktop])
const myAnimationState = useAnimationState(() => createAnimation(refs, isDekstop), [isDesktop])

It would indeed be very straightforward to have useExposeAnimation accept both a ref and a state variable.

We might consider renaming useAnimation to useAnimationRef to always have it clear which version you are using? Not sure if we have a preference for either one for normal use.


I think solution 1 can become more generic; we don't have to rely on expose/exposed, the global store, triggering listeners. The only reason solution 1 could work in the first place, is that we know that the hook gets executed when the component rerenders, and at that point we might have a new animation available on the ref. If that wasn't the case, there is no way to reliably sync a ref, as it can also be set outside of rerender cycle.

With that in mind, I think we can basically do:

function useAnimationState(animation: RefObject<gsap.core.Animation>): gsap.core.Animation | undefined {
  const [state, setState] = useState(animation.current);
  setState(animation.current);
  return state;
}

Thinking about it, we could just return animation.current from that function.

Because technically this would also work:

const isDesktop = useMediaQuery(...)
const myAnimation = useAnimation(() => createAnimation(refs, isDesktop), [isDesktop])

useEffect(() => {

}, [myAnimation.current]);