nandorojo / dripsy

🍷 Responsive, unstyled UI primitives for React Native + Web.
https://dripsy.xyz
MIT License
2.04k stars 81 forks source link

[RFC] Universal animation component library #46

Closed nandorojo closed 3 years ago

nandorojo commented 4 years ago

This issue is more of a RFC.

What

There is an opportunity to make something like react-native-animatable that is powered by Reanimated 2.x. I think it would follow an API similar to CSS transitions. I'm looking to framer/motion for inspiration on that, since it's super clean and easy to use.

How

My top priority would be to achieve transitions at the component level, without any hooks, and with the least amount of code. It should be as simple as possible – no config. This seems like a great DX:

const color = loading ? 'blue' : 'green'

<Text transitionProperty={'color'} sx={{ color }}/>

Under the hood, it would only use reanimated on native. Components would intelligently transition properties you tell it to. I don't have experience with Reanimated 2 yet, but it seems like they provide hooks that would make this remarkably doable. We could even default components to have transitionProperty: all, such that all you have to write is this:

const color = loading ? 'blue' : 'green'

<Text sx={{ color }}/>

...and you get smooth transitions. Not sure if that's desired, but just spit balling.

We could use CSS transitions and keyframes on web, since RNW supports that. I'm not married to a specific method of solving this problem, though, so maybe Reanimated will work on web too.

In addition to property transitions, animations would be great:

<View from={{ opacity: 1 }} to={{ opacity: 0 }} />

Today

With react native web, CSS animations are really straightforward:

const animationKeyframes = { from: { opacity: 0 }, to: { opacity: 1 } }
<View 
  sx={{ animationKeyframes }}
/>

Same goes with transitions:

const color = loading ? 'blue' : 'green'

<Text sx={{ color, transitionProperty: 'color' }}/>

The problem is that this code is not universal. It only works on web. In my opinion, using Platform.select or Platform.OS === 'web' is an anti-pattern when it comes to styles. That logic should all be handled by a low-level design system. Dripsy has made strides for responsive styles in this regard. Smooth transitions with a similar DX would be a big addition.

Final point: good defaults

In the spirit of zero configuration, this library should have great defaults. It should know the right amount of time for the ideal transition in milliseconds. I would look to well-designed websites to see what the best kind of easing function is, and this would be the default.

There is a plethora of "unopinionated" animation projects for React Native. There are few that are dead-simple, highly-performant, and have professional presets from the get go. I don't want to create an animation library. Only a small component library that animates what you want, smoothly, without any setup.

nandorojo commented 4 years ago

There has been discussion about native support from Framer Motion: https://github.com/framer/motion/issues/180

I'm not aiming to recreate every feature they have. Only the simple parts. This needs to solve 80% of the problems, not all of them.

cmaycumber commented 4 years ago

This would honestly be awesome.

Reanimated v2 in of itself is a leaps and bounds improvement API over the API in v1 imo.

What do you would be the most important features to knock out right out of the gate?

nandorojo commented 4 years ago

Reanimated v2 in of itself is a leaps and bounds improvement API over the API in v1 imo.

Agreed, I found v1 almost unusable.

What do you would be the most important features to knock out right out of the gate?

I'm torn between animations and transitions, but I think simple style transitions would be the most important at first.

Expo + Next.js is an incredible stack. And yet, achieving this is a hassle:

const color = loading ? 'blue' : 'green'

<Text transitionProperties={['color']} sx={{ color }}/>

That doesn't make sense to me.

Doing this for responsive style arrays, and for function style props would be more difficult. I think that could be step 2.

Tracking if this variable changed to drive an animation is more of a challenge on native:

const color = loading ? 'blue' : 'green'

<Text transitionProperties={['color']} sx={{ color: [loading && 'blue', loading && 'green'] }}/>

On web, doing the above already works for me if I use the transitionProperty CSS key in sx.


I'm not sure if transitions or animations are more important, but I think transitions happen more often, and are currently a bit more difficult to drive on native. You can use Reanimated v1's Transition, which is actually pretty nice, but it requires you to imperatively call ref.current.animateNextTransition() which is annoying.

Another idea could be to use Reanimated's Transitioning.View component under the hood, and when a certain style prop changes, we could run a transition (on native) by calling ref.current.animateNextTransition(). The downside with this is you get less control over the style change, and it won't look consistent with web. Could still be worth playing with, though. If we went in this direction, we could rely on the useImperativeHandle hook to make sure we forward the refs down properly.

nandorojo commented 4 years ago

It’s possible that running the animations is an easier start than the transitions, I’ll look into both. I wonder if this should live separately from the core Dripsy package. It would maybe be nice if it did, but I’m not sure if that’s possible, since it might have to read in the internal styles after the sx prop has been parsed.

cmaycumber commented 4 years ago

I wonder if this should live separately from the core Dripsy package. It would maybe be nice if it did, but I’m not sure if that’s possible, since it might have to read in the internal styles after the sx prop has been parsed.

I was wondering the same thing. Maybe it's possible to somehow merge the base style property and sx when the animations are being applied. But that I'm not sure of. It does however make sense for the library to be separate I can definitely see people choosing to use this even if they don't use dripsy.

nandorojo commented 4 years ago

Maybe it makes the most sense for it to be its own package that dripsy uses under the hood. If you use dripsy, you get it out of the box, but otherwise you don’t have to.

This way, the animation lib is receiving the final parsed style objects from dripsy itself.

I’d have to think through how creating a themed component would work in that case. It could be messy if every lib just re-exports React Native in its entirety.

One argument in favor of including it in Dripsy (which I’m not stuck to) is that you could use it for only animated styles and not even know about anything else. If you don’t use responsive styles or a theme, dripsy works just like a normal RN element (or at least it should...)

nandorojo commented 4 years ago

Another thought here, the animated library would basically only need to export View and Text now that I think about it. And an HOC for making your own.

nandorojo commented 4 years ago

I just checked out the Framer Motion docs. Pretty crazy how simple this is: https://www.framer.com/api/motion/animate-shared-layout/

cmaycumber commented 4 years ago

That's pretty crazy. Some of those were relatively complex animations with just a few lines. I've never used framer personally but have definitely kept an eye on it.

If we could replicate something even close to that it would be super useful in RN.

nandorojo commented 4 years ago

Yeah...would be awesome. For now, even looping through object keys isn’t really supported in Reanimated 2, so I’m trying to put together a demo that can loop though each style change.

nandorojo commented 4 years ago

I put together an idea here: https://github.com/nandorojo/redrip

<View animate={{ width }} />

It takes an animate prop which acts essentially as a CSS transition for values that change. I haven't taken a stab at animations yet, such as from -> to keyframes.

cmaycumber commented 4 years ago

This is pretty sweet. I have some basic animations that might be able to use this on the app I'm working on I'll try it out.

cmaycumber commented 4 years ago

If this new library did work closely with Dripsy one cool feature might be to have this library key off theme keys to create animations.

For example:


const theme = {
 durations: {
   short: 200
 }
}

<View animate={{width }} duration='short' />
nandorojo commented 4 years ago

Agreed. That would be pretty doable.

I'm not sure if the transitions should live in the normal style/sx prop, or in something like animate. The benefit of animate is that there might be special things we want to do with those styles, such as turn them into transition-property CSS values for web.

nandorojo commented 4 years ago

React spring has discussed integrating reanimated. This seems really promising.

https://github.com/pmndrs/react-spring/issues/764

However, it hasn’t been updated on npm in 6 months, so not sure if there’s much movement.

nandorojo commented 4 years ago

Haven't seen any movement there. React Spring looks pretty solid, here's an example (https://snack.expo.io/@nandorojo/biased-popsicle). I do prefer a component-only API personally.

I might try it for now, but I definitely have my eye on Reanimated 2 as a universal solution. I'll be trying both out as Reanimated approaches stability.

I haven't actually upgraded to Reanimated 2 in my app yet. I might have to wait for Expo SDK40 (since I assume it'll be more stable then.) That said, I may upgrade sooner if I can justify the refactor.

nandorojo commented 4 years ago

Something I haven't been able to figure out here.

I like this api from framer-motion:

<View initial={{ opacity: 0 }} animate={{ opacity: 1 }} />

The idea is, initial (if set) is the first state, which runs an animation to animate. Also, any subsequent updates in animate also animate.

Right now, the repo I shared that I'm working on does this:

<View animate={{ opacity: visible ? 1 : 0 }} />

That works well. I'm not sure how to get the initial to work, though. I tried making it into another style with useAnimatedStyle, and putting it first, but that doesn't seem to work. Maybe I should set these in useEffect or something?

nandorojo commented 4 years ago

Turns out, I got all of this to work pretty gracefully. It's really nice. The only problem is that performance is (or at least seems to be) quite laggy on web. Not sure if Reanimated has really optimized for web yet or not. I'm going to see if using keyframes (or react-spring/framer-motion) makes a big difference. I'll push my changes soon, it's pretty cool.

nandorojo commented 4 years ago

Latest changes are pushed here: https://github.com/nandorojo/redrip

The example has a side-by-side comparison of @react-spring/native and react-native-reanimated.

Here is a video showing the two on iOS.

Thoughts

It's hard to say definitively which is more performant from such a minor example. Feel free to try them on ios/web to see. I could also try the same with framer-motion on web, but I'd prefer not to, since this wouldn't use RNW.

A few takeaways

If it turns out that Reanimated isn't mature enough on web, I think @react-spring/native could be a good substitute in the meantime. I'd have to make sure there is parity between the APIs, but I'm feeling confident in being able to do so. I'd also make sure the default configs (such as spring configs, etc.) are the same.

Next steps

While I've been hesitant to upgrade my app to Reanimated 2 (due to its alpha stage), I think I'm going to upgrade and try to bring this idea into it. It's possible I could release a beta for the universal animation lib very soon.

nandorojo commented 3 years ago

I got a lot of help from @terrysahaidak on Twitter for how I should structure this. I'll be pushing some updates soon, and once https://github.com/software-mansion/react-native-reanimated/issues/1511 is fixed I'll be comfortable merging this into my app.

I think I've nailed the ideal API. You can animate your components with either of the following options:

  1. Power animations via state / component props only (easier, zero config, just use state)
  2. Run transitions based on pre-defined states, using a hook (more performant)

1) props-based animations

import { View } from 'redrip'

const Loader = ({ isLoading }) => {
  return <View initial={{ opacity: 0 }} animate={{ opacity: isLoading ? 1 : 0 }} />
}

This API is ideal if you rely on state changes to drive your animations, such as loading states, etc. It's also very clean: any values in animate will smoothly transition as you change them. If you set an initial prop, then they will start there, but this isn't required.

I might rename initial to from. Not sure tbh.

Keyframes

This API also lets you run a simple enter animation, just like CSS keyframes:

import { View } from 'redrip'

const FadeIn = () => {
  return <View initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ type: 'spring' }} />
}

If you never update the values in animate, then this should be just as performant as option 2.

Super easy 🔥

2) predefined states, better performance

This API looks more like react-spring. It's more performant, since you never trigger re-renders and keep everything on the native thread.

Predefine static states, and transition to them as you please.

const animator = useAnimator({
  initial: { opacity: 0 },
  open: { opacity: 1 },
  pressed: { opacity: 0.7 }
})

const onOpen = () => {
  if (animator.current === 'initial') {
    animator.transitionTo('open')
  }
}

<View animator={animator} />

The benefits of this API become much more apparent as your styles have more than just one value. The pseudocode doesn't really do it justice.

TODO

nandorojo commented 3 years ago

Something I always thought has been a pain in React Native: animating dynamic height, such as auto.

Even though it's based on a state change, the DX for this is really clean. It's nice for those times when you don't want to set up animated values and just want layout shifts to be smoother.

The idea use-case I see: you're showing a skeleton before some content loads, and then you want it to smoothly transition to its next state.

function Measure() {
  const [{ height }, onLayout] = useLayout()

  const [open, toggle] = useReducer((s) => !s, false)

  return (
    <>
      <Drip.View animate={{ height }} style={{ overflow: 'hidden' }}>
        <View
          onLayout={onLayout}
          style={{ height: open ? 100 : 300, backgroundColor: 'green' }}
        />
      </Drip.View>
      <Button title="toggle" onPress={toggle} />
    </>
  )
}

There's no magic: you just wrap a variable-height element with our library's View, measure the child, and pass the height to the animate prop.

https://user-images.githubusercontent.com/13172299/102846688-a7167680-43de-11eb-8a3f-6b59c32e9527.mp4

You'll notice in the video that the shrinking of content is a bit less smooth than the expansion. I think for most situations, this is fine, since placeholder content is usually smaller than real content.

If you need more fine-grained control, you can use fixed heights. But I can probably count on one hand the number of times I've used fixed heights in real-world apps, besides images.

nandorojo commented 3 years ago

This is almost ready to push online. I just managed to get mount/unmount animations to work using framer-motions's AnimatePresence.

Video: https://twitter.com/FernandoTheRojo/status/1349884929765765123

nandorojo commented 3 years ago

Published: moti.fyi

nandorojo commented 3 years ago

Dripsy v3 has docs for usage with Moti: https://github.com/nandorojo/dripsy/blob/typez/README.md#%EF%B8%8F-animated-values

We've come full circle.

cmaycumber commented 3 years ago

@nandorojo I'm starting a component lib using dripsy, I also want to include Moti for animations. Do you know if there are any performance implications if I just used the MotiViews/MotiText as the base for all the components animations work out of the box?

nandorojo commented 3 years ago

My guess is there may be some. As far as I know, creating Animated shared values has some cost to it. For example, if every component has useAnimatedStyle under the hood, this may cause some issues.

That said, I don't know this to be true with certainty. It's just from stuff I've read on Reanimated issues. So I recommend asking on Reanimated's repo to get a better answer. It would certainly be cool to have both Moti and Dripsy working with every component.

When asking on Reanimated, I would want to know if 1) there is cost to useAnimatedStyle with no styles, 2) if there is cost to creating a shared value with useSharedValue since Moti uses this under the hood, and if Animated.View in general is expensive to use vs a normal View.

cmaycumber commented 3 years ago

That said, I don't know this to be true with certainty. It's just from stuff I've read on Reanimated issues. So I recommend asking on Reanimated's repo to get a better answer. It would certainly be cool to have both Moti and Dripsy working with every component.

Gotcha that makes a lot of sense; thanks for the insight. I'll play it safe in the meantime until I know a little bit more.

nandorojo commented 3 years ago

Sweet. I recommend using 2.3.x, I heard it has much better performance for many animated nodes.

https://twitter.com/kzzzf/status/1421104290085576710?s=20