framer / motion

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

RFC: sizeTransition #205

Closed mattgperry closed 4 years ago

mattgperry commented 4 years ago

Overview

A motion prop for HTML elements, sizeTransition, that enables content to animate between layout sizes.

Mirroring positionTransition, sizeTransition would animate components when they change width/height as the result of a re-render.

<motion.div sizeTransition>
  <Content />
</motion.div>

Use-case(s)

Proposal

sizeTransition can be defined either a boolean:

<motion.div sizeTransition />

or a Transition:

<motion.div sizeTransition={{ duration: 1 }} />

Release 1: width/height

In the first iteration, it'll animate width and height from their previous to their new measured dimension.

Release 2: scaleX/scaleY

By default, animating width and height will trigger layout. This can be slow and is better avoided for consistent 60fps animations. A more performant way would be animate scale.

This will necessitate either an inversion prop or component that can "undo" the scaling effect (so only the container visually scales).

Prop:

<motion.div sizeTransition>
  <motion.div invertScale>{contents}</motion.div>
</motion.div>

Component:

<motion.div sizeTransition>
  <InvertScale>{contents}</InvertScale>
</motion.div>

At parent scales of < ~0.05 - ~0.1 we can fade this element out to prevent scaling to Infinity.

Is the presence of an inversion prop/component a pre-requisite to enable the scale performance animation? And/or the signal to use scale over width/height?

Release 3: Interactions with positionTransition (prospective)

width and height will naturally reflow-components every frame, but it isn't performant. But scale doesn't effect layout, so its surrounding components would "jump" into their new layout.

It'd be good if at least sibling components could respond to each other's scale resizing with a co-ordinated animation. So for instance this code:

<motion.div style={{ height: isOpen ? 'auto' : 0 }} sizeTransition positionTransition>
  <InvertScale>{contents}</InvertScale>
</motion.div>
<motion.div style={{ height: isOpen ? 'auto' : 0 }} sizeTransition positionTransition>
  <InvertScale>{contents}</InvertScale>
</motion.div>

As one component animate in size via scale, its surrounding components would animate in position using x/y.

Framer X considerations

As sizing isn't abstracted the way positional layout is to x/y, this should drop-in to Frame. Animating the position of surrounding components would require them to be in a DOM layout, ie Stack.

souporserious commented 4 years ago

The initial and subsequent releases sound great 👍. I think being able to solve something like a collapse animation with scale/translate would be amazing!

I'm curious about your thoughts on handling scaling two pieces of text that have changed in size? I think it would be used alongside/instead of invertScale. I was messing around with react-flip-toolkit a couple of weeks ago and it was the one thing I really wanted but the library didn't seem to support. I ended up with my own approach to solve this. The idea was if the fontSize has changed when performing a FLIP then use normal scaling rather than trying to scale the box it's contained in.

sami616 commented 4 years ago

Sounds great!

nvh commented 4 years ago

Great writeup! I like the initial idea of the sizeTransition prop, but think things will get hairy when we try implementing them with scale. Using a invertScale prop on motion.div for that feels wrong to me, so I'd prefer a separate component.

What I don't like about it is that there's now way to couple it to the sizeTransition that is happening, what if there multiple levels of nested sizeTransition props and InvertScale components? Come to think of it, would a dedicated SizeTransition component not be a better fit for this than adding it to motion.div? That would also solve the problems with Framer X compatibility as long as that is not using full DOM-layout yet.

rijk commented 4 years ago

I like the proposal as well, mainly the first one (width/height). Should make it easy to implement something similar react-collapse. This is kind of like an accordion, but I mainly use it to make containers that grow animated when something is added to the bottom (say, a comment is added).

Here's the component I use today:

import React, { memo } from 'react'
import { useSpring, animated } from 'react-spring'
import { useMeasure } from './useMeasure'

const Collapse = ({ children, className, isOpen = true }) => {
  const [ bind, measured ] = useMeasure()
  const { height } = useSpring({ height: ( isOpen ? measured.height : 0 ) })

  return (
    <animated.div className={className} style={{ height, overflow: 'hidden' }}>
      <div {...bind}>
        {children}
      </div>
    </animated.div>
  )
}

export default memo( Collapse )

The trickiest thing with this component is when you have another nested Collapse. For instance, when a comment has a "3 replies" link that can be expanded, and you want those replies to animate as well. In that case the parent Collapse will animate too slowly, as it is reacting to the changes in height triggered by the child Collapse (instead of an instant change in height, like normally would be the case).

This is a very tricky challenge, for which there is no (real) solution as far as I know. There are workarounds for it, but always based on the childs being fixed height, or knowing from the parent whether it contains active child Collapses.

Do you have thoughts on this? Would motion be able to solve these issues?

mattgperry commented 4 years ago

@nvh We discussed this offline but for the good of the thread - I do see the value of InvertScale being a separate component as we actively don't want to user to control any facet of it. Maybe even including its styling? We would need to prototype.

But I do think sizeTransition should be a prop as its quite composable with other functionality ie positionTransition, motion values, hover animations etc. It's also the element for layout so we'd retain semantics, motion.li etc.

@rijk We can already do accordions as we can animate height between any two value types (in this case 0 and "auto") - see here https://codesandbox.io/s/framer-motion-accordion-qx958 - but yeah sizeTransition would be able to automatically animate to accomodate new content.

If this was to work the same way as positionTransition, (measure on render, then during useLayoutEffect) as long as all the nested components were being changed as a result of the same render cycle then I don't see this being a problem.

A more difficult situation is if we have a component that should update in size as a result of a child being removed from the tree asyncronously, via an exit animation triggered by AnimatePresence. As in this case AnimatePresence will only remove the outgoing component, therefore changing its size, once its exit animation has completed. So sizeTransition will detect no change in size when the render happens, and the eventual change in size will happen locally within AnimatePresence.

<motion.div sizeTransition>
  <AnimatePresence>
    <motion.div exit={{ opacity: 0 }} positionTransition />
    <motion.div exit={{ opacity: 0 }} positionTransition />
    <motion.div exit={{ opacity: 0 }} positionTransition />
  </AnimatePresence>
</motion.div>

However I don't think this is the fault of the API proposal, more of a difficult consideration it should be developed with in mind. The way the motion component is wired up should make this kind of communication quite possible.

@souporserious I'd have to see an example but I do think that kind of things would be better solved with a Magic Move-style animation which I think is a medium-term thing.

eric-personal commented 4 years ago

@rijk what does useMeasure do ? Would you mind sharing this hook. Trying to implement your Collapse component for a parent component that changes height based on how many children is added or removed.

rijk commented 4 years ago

@eric-personal it's a simple hook that measures the element. Something you probably won't need when using framer-motion, as it supports animating to "auto" values (example).

Here's the code:

import React, { useState, useRef, useEffect } from 'react'
import ResizeObserver from 'resize-observer-polyfill'

export function useMeasure() {
  const ref = useRef()
  const [bounds, set] = useState({ left: 0, top: 0, width: 0, height: 0 })
  const [ro] = useState(() => new ResizeObserver(([entry]) => set(entry.contentRect)))
  useEffect(() => {
    if (ref.current) ro.observe(ref.current)
    return () => ro.disconnect()
  }, [])
  return [{ ref }, bounds]
}
rijk commented 4 years ago

@InventingWithMonster apologies if this is a dumb question, I'm not super well versed in the motion codebase yet. It's still not clear to me how sizeTransition will handle nested animations? For example, with my Collapse component I have the following tree:

<Collapse key="comments">
  <Comment>
    <Collapse key="replies">
      <Reply />
      <Reply />
    </Collapse>
  </Comment>
</Collapse>

Now, when a reply is added, the <Collapse key="replies"> will animate as intended. But the outer <Collapse key="comments"> will animate more slowly, as it responds to the change in size triggered by the inner animation.

The only ways to work around this are to programatically disable animations on one or the other, but you can only do this when you know which should animate. And I cannot know that, because at any time a new Reply or Comment could come in (from someone else).

Would this sizeTransition proposal support this kind of nested use?

mattgperry commented 4 years ago

In that instance the parent animation wouldn’t be triggered because sizeTransition only measures changes in height as the result of a re-render. Which would be the one state change of the expanded comments.

Even so, this isn’t going to be an issue in any event as I’ve been prototyping and I’m going skip the first step of animating width and height. I saw the accordion in action after making it and it’s ok but the results from scale will be much smoother.

rijk commented 4 years ago

Sweet! Makes a lot of sense.

loganpowell commented 4 years ago

@InventingWithMonster

I'd have to see an example but I do think that kind of things would be better solved with a Magic Move-style animation which I think is a medium-term thing.

WOOOOOOOOOOOO 🔥

loganpowell commented 4 years ago

Some prior art you might like: https://github.com/aholachek/react-flip-toolkit

rijk commented 4 years ago

Has this been added? :)

Edit: was checking the code, seems halfway done.. If the scaling proves difficult, a simple width/height transition (combined with overflow: hidden) would already be very valuable for auto animating containers.

mattgperry commented 4 years ago

Ah yeah I should have added a note. The WIP branch is now feature/layout-transition

seed-of-apricot commented 4 years ago

Is this feature already added?

i-am-the-slime commented 3 years ago

Maybe now?

mattgperry commented 3 years ago

This is all superseded by the layout prop.