react-spring icon indicating copy to clipboard operation
react-spring copied to clipboard

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

Open krispya opened this issue 2 years ago • 5 comments

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:

  • Inertia based gestures. This can be inertia scrolling or intertia based reordering. A common example on mobile would be flicking an item, predicting where the item would land and then doing some logic based on that. Or having an inertia based drag snap to a grid. Currently these are all obtuse to do with both libraries, but very simple with Framer Motion.
  • A reordering component. This is another very convenient abstraction in Framer Motion that requires both animation and gesture. It allows a user to easily define an array of items and callbacks for dragging and reordering them. I agree with the Framer team that this goes beyond an UI pattern to boilerplate abstraction.
  • Gesture driven animations via props. The ability to intentionally define animation states based on gestures, such as hover or drag, is intuitive and reduces code for simple cases.
  • Viewport driven animations via props. Like above, except for viewport properties like if the element is in view. This is another intentional animation type that new users look for.

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

krispya avatar Jan 28 '22 18:01 krispya

I like the idea, it'd be interesting to see if @dbismut has any thoughts on this. 👍🏼

joshuaellis avatar Jan 28 '22 19:01 joshuaellis

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

dbismut avatar Jan 28 '22 20:01 dbismut

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.

krispya avatar Jan 28 '22 23:01 krispya

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:

  • is it only good in the project i'm working on?
  • Should it be a component as opposed to a hook?

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...

joshuaellis avatar Mar 15 '22 10:03 joshuaellis

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.

krispya avatar Mar 15 '22 23:03 krispya