pmndrs / react-spring

āœŒļø A spring physics based React animation library
http://www.react-spring.dev/
MIT License
28.2k stars 1.19k forks source link

[feat]: Create an animation abstraction for spring and gesture #1826

Open krispya opened 2 years ago

krispya commented 2 years ago

A clear and concise description of what the feature is

I think we should explore creating an abstraction for use-spring and use-gesture in the same vein as Framer Motion. The goal of the abstraction would be to have a set of components and hooks that cover a higher order of intention. Not so much "I want to use this gesture" and "I want to have this physics simulation" but "I want my element to scale when dragged."

Why should this feature be included?

I have been sitting in the Discord for a while now and trying to help people with their issues. One of the most common I find is when people need to use a gesture and an animation together. It isn't always straight forward how to think about these problems, especially if the goal is a gesture based on physical properties (such as inertia). Also, even in everyday driving of these libraries there is just a ton of boilerplate that gets redone to create basic gesture based animations. A simple abstraction would save lines of code for something like animating a property on hover.

Here are some examples of relevant use cases that I could think of:

I think a bonus would be for such an abstraction to apply to r3f so that same intentional concepts cover 3D, but I know this adds an order of magnitude of complexity.

Please provide an example for how this would work

No response

joshuaellis commented 2 years ago

I like the idea, it'd be interesting to see if @dbismut has any thoughts on this. šŸ‘šŸ¼

dbismut commented 2 years ago

It's an interesting proposition. I'm not exactly sure how this would work technically but happy to help on the gesture part!

krispya commented 2 years ago

Here is a naive attempt at implementing a Motion component for the sake of conversation: https://codesandbox.io/s/eager-monad-kqnyl?file=/src/App.js

I wasn't sure how to set a rest state dynamically when useSpring requires default values up front. I thought it would be helpful to be able to specify values that I expect to change but not necessarily their starting values, which I would set later once the DOM computes them.

Anyway, this naive attempt treats the Motion component as like a container for gesture animations. If a specific element in the children needed to have the gestures attached they would need to have their ref forwarded. This is different from Framer Motion which has the same function that makes the element also handle gesture and animation logic.

joshuaellis commented 2 years ago

Here's an example of a "basic carousel" spring, you can only move it left and right & it caps at 0 and the length of the scroll container. My biggest concerns are:

If it should be a component, then we're breaching into UI library territory, which i'm not sure we want to do unless we went the radix-ui route of 0 style...

Here's the hook though (name could be better imo):

export const useCarouselSpring = <TElement extends HTMLElement>(): [
  draggerRef: MutableRefObject<TElement>,
  bind: (...args: any[]) => ReactDOMAttributes,
  styles: SpringValues<{ x: number }>
] => {
  const draggerRef = useRef<TElement>(null!);

  const currentPosX = useRef(0);

  const [styles, api] = useSpring(
    () => ({
      x: 0,
    }),
    []
  );

  const bind = useDrag(
    ({ movement: [xMove], active }) => {
      const newX = currentPosX.current + xMove;

      const { current: dragger } = draggerRef;
      const maxMovement = dragger.scrollWidth - dragger.clientWidth;

      if (active) {
        dragger.style.cursor = "grabbing";

        api.start(() => ({
          x: newX >= 0 ? 0 : newX < -maxMovement ? -maxMovement : newX,
        }));
      } else if (!active) {
        dragger.style.cursor = "grab";

        if (newX <= 0) {
          currentPosX.current = newX < -maxMovement ? -maxMovement : newX;
        } else {
          currentPosX.current = 0;
        }
      }
    },
    {
      axis: "x",
    }
  );

  return [draggerRef, bind, styles];
};

Is it about integrating all gestures in hooks or creating abstractions around common use cases...

krispya commented 2 years ago

I do think we should be careful to separate two concerns. One is more hooks and declarative methods for building animated interactions to match the ease of use and coverage of Framer Motion's api. From this perspective, the goal is to provide tools composing common motion/interaction pairs like whileHover and then some of the more complicated ones like momentum interactions. I think this alone would be a great help as there is a non-trivial amount of these boilerplate pieces that need to be built for handling iOS-like UI. A good coverage of these kinds of interactions is gone over in Alex Holachek's old talk here which I know influenced use-gesture: https://www.youtube.com/watch?time_continue=6&v=laPsceJ4tTY&feature=emb_title

For example, how great would it be to have a convenience hook for working with projected gesture values? And having this integrated with gesture-informed momentum! Another tough one is handling boundaries. use-gesture offers a method for doing this but it is lacking since it can only be aware of the gesture information so anything that requires physics or scaling information gets muddy.

The other concern I think is building poimandres powered UI. I agree with you that this is tricky and may be better to have a collection of sandboxes (and code examples) for recipes that are also documented and easy to reference from the website. Otherwise we would need to go the route of something like React Aria which allows you to build UI using hooks, but then you have to handle states and like R3F we'll have to have a sane event system where we can guarantee all these UI interactions happen in a given priority... It is a whole project to itself that is maybe left for another day.

What we are currently doing at my work is using a bunch of "Base" components that handle all the interaction and state logic but have no styling. We then build as many specific components out of the base functionality as we need. For example a basic Button component looks like this:

export default function Button({
    isDisabled = false,
    children,
    onPress,
    isNaked = false,
    hasShadow = true,
    ...otherProps
}) {
    const buttonClassNames = classNames(styles.Button, {
        [styles['is-naked']]: isNaked,
        [styles['has-shadow']]: hasShadow,
    });

    return (
        <ButtonBase
            className={buttonClassNames}
            hoverClassName={styles['is-hovered']}
            isDisabled={isDisabled}
            onPress={onPress}
            {...otherProps}
        >
            {children}
        </ButtonBase>
    );
}

And then ButtonBase looks like this (useInteraction is just a wrapper for use-gesture that adds Press for device normalization):

export default function ButtonBase({
    isDisabled = false,
    isActive = false,
    children,
    className,
    hoverClassName,
    activeClassName,
    onPress = noop,
    onPressStart = noop,
    onPressEnd = noop,
    onLongPress = noop,
    onHover = noop,
    ...otherProps
}) {
    const styleProps = useStyleProps(otherProps);
    const ref = useRef();

    const { bind, isHovered, isPressed } = useInteraction({
        onPress: onPress,
        onPressStart: onPressStart,
        onPressEnd: onPressEnd,
        onLongPress: onLongPress,
        isDisabled: isDisabled,
    });

    useEffect(() => {
        onHover();
    }, [isHovered, onHover]);

    const buttonClassNames = classNames(styles.ButtonBase, className, {
        [hoverClassName]: hoverClassName && (isHovered || isPressed),
        [activeClassName]: activeClassName && isActive,
    });

    return (
        <div
            {...bind()}
            className={buttonClassNames}
            style={{
                ...styleProps,
                cursor: isDisabled ? 'default' : 'pointer',
            }}
            tabIndex="0"
            ref={ref}
        >
            {children}
        </div>
    );
}

For a more complicated UI like dropdown we do this where the state is passed in (also using a custom scrollbar not built into the base component since it is style neutral):

export default function Select(props) {
    return (
        <SelectBase {...props} className={styles.Select}>
            <SelectButtonBase className={styles.button} />
            <SelectMenuBase className={styles.menu} maxHeight={300}>
                <Scrollbars
                    className="os-host-flexbox"
                    options={{
                        scrollbars: { autoHide: 'move' },
                        nativeScrollbarsOverlaid: { showNativeScrollbars: true },
                    }}
                >
                    {props.list.map((item, i) => {
                        return (
                            <SelectItemBase
                                key={i}
                                value={item}
                                className={styles.item}
                                hoverClassName={styles['is-hovered-item']}
                                selectedClassName={styles['is-selected-item']}
                            />
                        );
                    })}
                </Scrollbars>
            </SelectMenuBase>
        </SelectBase>
    );
}

This is inspired by React Aria/Spectrum but also how Drei allows for composing intention.