framer / motion

Open source, production-ready animation and gesture library for React
https://framer.com/motion
MIT License
22.56k stars 749 forks source link

layoutTransition using height instead of transforms #275

Closed rijk closed 4 years ago

rijk commented 4 years ago

Comment moved from #268.

For the new layoutTransition was opted for a transform + useInvertedScale() approach. There are several practical issues with this when trying to implement something like a auto growing container for items:

Describe the solution you'd like An option to animate height (+ overflow:hidden) on the parent container instead of using transforms. Ideal would be if the overflow:hidden would be applied automatically as well, and only while the animation is running.

Here is an example of the effect I like to achieve: https://codesandbox.io/s/example-growing-container-t3sq2 -> but of course, without setting fixed heights (it also animate when content changes).

mattgperry commented 4 years ago

Hi Rijk

We aren't going to implement this because of the performance implications - I'd rather keep working on the transform approach.

The good news is you can cover a lot of layoutTransition use-cases simply by animating height via animate. For instance in your use-case animating height: 20 and height: 'auto'.

Also if you didn't know, you know you can use useInvertedScale without a new component:

const scaleX = useMotionValue(1)
const scaleY = useMotionValue(1)
const inverted = useInvertedScale({ scaleX, scaleY })

return (
  <motion.div layoutTransition style={{ scaleX, scaleY }}>
    <motion.div style={inverted} />
  </motion.div>
)

Likewise there is an experimental API in the works for animating surrounding elements into their new positions. You can already use the UnstableSyncLayout component today:

<UnstableSyncLayout>
  <motion.div layoutTransition style={{ height: isOpen ? 0 : 200 }} />
  <motion.div positionTransition />
</UnstableSyncLayout>

We will be trying to land on a better name ASAP.

rijk commented 4 years ago

Hi Matt, I’ll give it a try, thanks. What I’m trying to do is not animate from 20 to auto, but auto-animate from [previous height] to [new content height]. Like example 2 at https://codepen.io/nkbt/pen/MarzEg?editors=101. If I had to name this as a property I would call this heightTransition. Is this something you would be interested to accept a PR for?

mattgperry commented 4 years ago

You should already be able achieve this by forcing an animation using imperative animation controls

const controls = useAnimation()

useEffect(() => {
  contentHasChanged && controls.start('height', 'auto')
}, [])

return <motion.div animate={controls} />

There's also a PR in progress that would allow you to do this declaratively via an invalidateAnimate prop:

<motion.div invalidateAnimate={isOpen} animate={{height: 'auto'}} />

Edit:

Ahh ignore the above, sorry I understand the auto measurements will be off because of the lifecycle of the above.

rijk commented 4 years ago

Sounds great, thanks for the pointer!

mattgperry commented 4 years ago

Sorry I thought about it a little longer and realised the auto measurements will be off as they'll all take place after the content is switched.

rijk commented 4 years ago

Ah. So is there a way to do this, leveraging the measurement code you’ve written for layoutTransition? The thing that was especially appealing there is that it runs on (and only on) component renders. This prevents the (otherwise very hard to solve) problem of nested 'autoHeight' components having a slowed down animation because they are responding to each other’s animations. And also, I just really want to switch to framer-motion :)

mattgperry commented 4 years ago

Try this custom hook! https://codesandbox.io/s/example-growing-container-39sdm

rijk commented 4 years ago

Hey Matt, I think I might have confused things with the expand/collapse stuff. That’s actually not the main thing; the main thing is the auto growing of the container. I’ve added an "Add" button to your Sandbox: https://codesandbox.io/s/example-growing-container-7xx10

What is supposed to happen is, when clicking add, a reply is added and the container slowly grows to its new size to not confuse the user (e.g. in case he was reading the comment below it and it suddenly jumps to a new position).

What happens now is the container stays the same height (while the replies are added and overflow):

image

mattgperry commented 4 years ago

This hook will run whenever a dependency changes - so you can just pass through num too:

useAutoHeightAnimation([expanded, num])
rijk commented 4 years ago

Aah, sorry I should have seen that. Works like a charm man! https://codesandbox.io/s/example-growing-container-7xx10

rijk commented 4 years ago

Sorry, spoke too soon. I noticed a glitch so I recorded my screen to slow it down. Here's a frame by frame:

image

image

image

image

image

image

So the parent is first scaled to the new height (by the browser?) and only then does the animation kick in, scaling it back and then animating up to the new height.

rijk commented 4 years ago

Same thing when collapsing:

image

image

image

image

image

rijk commented 4 years ago

I've slowed down the transition (and added overflow: hidden to the container) so you can really clearly see the glitch: https://codesandbox.io/s/example-growing-container-7xx10

It happens on expanding, collapsing, and adding replies.

rijk commented 4 years ago

By the way, this is a very frequently used pattern when you have a timeline or list of items that can change (e.g. notifications, to do items, news feed posts, comments etc — you animate the changes in so it's not jarring to the user). Is there really not something built into the library for it (like AnimatePresence)? I always get the feeling when something is so hard to do, I am doing something wrong :)

Like, an alternative solution to this would be to just wrap all <Comment>s in a Motion component that will animate them from 0 to auto when they are added to the tree (and of course disabling this on the initial page load).

rijk commented 4 years ago

Fixed the glitch! https://codesandbox.io/s/example-growing-container-d5oe5 (added a wrapper div so the ref is not on the animated component). There's still a glitch on the first expand, but we're getting there :)

mattgperry commented 4 years ago

You’ll probably get some luck removing visual glitches by changing the useEffect to useLayoutEffect.

It’s not that it’s difficult to do - the hook above proves that - it’s that once you put something in the API you can’t take it out (specifically in Framer where we have to support old projects), and it’s yet another concept to explain. Animating height and width is wrong - it’s bad for performance and because you can only animate round values it looks poor even at 60fps. I’d rather give people a snippet they can use on the side and work towards a proper solution by improving layoutTransition in the meantime.

rijk commented 4 years ago

Gotcha. I appreciate the added concerns you have as a library maintainer. Just thought this might be a common use case you overlooked.

Regarding animating width/height: I get the performance issues, but I think in this use case this is exactly what you want, as the point of the effect is to make the surrounding elements to gradually move to make space for the new item. Just scaling the new item doesn’t do the trick (even with the scale inverter), as the surrounding content will still snap into place without animation. Unless you can think of another way to achieve this?

Tried useLayoutEffect but doesn’t remove the glitch on the first expand unfortunately: https://codesandbox.io/s/example-growing-container-d5oe5

rijk commented 4 years ago

Tried a different angle, using positionTransition: https://codesandbox.io/s/example-growing-container-with-positiontransition-lv6cm

It looks very promising, I think this could work! Reading the docs it also seems like this was designed for what I’m trying to do here:

Add a positionTransition prop to the child components to allow them to smoothly animate into their new positions when an item is removed.

rijk commented 4 years ago

For completeness, here is the final implementation, using layoutTransition after all: https://codesandbox.io/s/example-growing-container-with-layouttransition-9d5tl

I tried to use this:

<UnstableSyncLayout>
  <motion.div layoutTransition style={{ height: isOpen ? 0 : 200 }} />
  <motion.div positionTransition />
</UnstableSyncLayout>

But the transitions were not in sync; the positionTransition was way more bouncy than the layoutTransition. The <UnstableSyncLayout> component didn’t really seem to have any effect; changing it to a fragment (<>) gave the exact same result.

So I ended up giving everything a layoutTransition (both the replies container and the surrounding comments), and that was basically the answer I think. In addition to moving the expanded state up of course, as of course the surrounding comments cannot be animated when the parent does not rerender.

Thanks a ton for the help and nudging me in the right direction. ✌️

mattgperry commented 4 years ago

Yeah the defaults are different for both transitions, the thinking being layoutTransition probably affects wider areas whereas positionTransition is more item reordering. You can give the props the same settings as transition to customise the animation used. Awesome that it works! That's some performant stuff ;)

ie layoutTransition={{ duration: 2 }}

rijk commented 4 years ago

Sorry to keep bothering.. 👀

I've worked out a more elaborate sandbox, which is closer to the component in my app I am trying to implement: https://codesandbox.io/s/timeline-with-height-transition-for-versions-tj9j3

It is a timeline with versions, each version containing its own comments. I've done the version expanding/collapsing using height 0/auto and AnimatePresence to animate out the collapsed version. This works great. However, it doesn't work well with the layoutTransition used for comments. The versions don't animate in response to content changes (for example, when adding a comment in version 2, version 3 jumps down instantly). As a result, when collapsing the replies, the container (which has overflow:hidden) instantly jumps to its new height, cutting of the animation:

Screenshot 2019-08-28 at 15 38 28

You can see this by toggling the replies in version 2 (or adding/deleting a comment).

What would your suggested approach be for this type of UI?

rijk commented 4 years ago

In case you were going to say "use layoutTransition".. Here's an example where I've used layoutTransition for the version content as well: https://codesandbox.io/s/timeline-with-positiontransition-everywhere-6fjrw (I've pulled the state up into the parent component, so that rerenders are triggered).

As you can see here a weird effect happens when switching versions or expanding replies, not really the accordeon type effect I tried to achieve.

rijk commented 4 years ago

I've coded another Sandbox using just height transitions, to demonstrate the intended effect: https://codesandbox.io/s/timeline-with-height-transition-everywhere-c55hb

Note that this only works on expanding/collapsing versions and replies, not on adding comments or replies. And also the performance sucks probably :)

XavierJ03 commented 4 years ago
const scaleX = useMotionValue(1)
const scaleY = useMotionValue(1)
const inverted = useInvertedScale({ scaleX, scaleY })

return (
  <motion.div layoutTransition style={{ scaleX, scaleY }}>
    <motion.div style={inverted} />
  </motion.div>
)

This solution was perfect to solve the visual distortion issue with layoutTransition