1

really sorry about that weirdly formulated question. I am looking to create a a short spring animation with react-motion. Please bear with me:

I have a component that loads a list of local directories and files inside a frame, a bit like a mac finder window, according to a selected base directory. So, depending on that arbitrary directory, the number of fetched files will vary and so the height of my frame will do too (until it reaches max-height).

Now, as soon as the content of the selected directory has been fetched, I want to spring open this component's frame dynamically to the right height (or max-height). And if I was to change the directory, I'd need the frame height to spring/adjust according to the number of elements fetched OR straight to the max-height.

Here is the relevant excerpt of the component:

  <Motion
    defaultStyle={{x: 30}}
    style={{x: spring(330, {stiffness: 270, damping: 17})}}
  >
    {({x}) =>  
      <List height={x}>
        {
          localTree.map(item =>
            <ListItem key={v4()}>
              <FileIcon isDirectory={item.isDirectory} />
              <span onClick={() => this.handleClick(item.id)}>
                {item.baseName}
              </span>
            </ListItem>
          )
        }
      </List>
    }
  </Motion>

You can see that, at the moment, the defaultStyle and style variables are statically set. It works fine, especially because I've set the <List /> component's style to overflow: scroll so that if there are more elements than 330px in this case, a little scrolling makes it naturally browsable.

But that is not what I ultimately want. I want thedefaultStyle={{x: 30}} and style={{x: spring(330, )}} to be dynamically set by the height of the cumulated number of <ListItem /> child elements.

How should I tackle this? Should I add a parent component somewhere?

Jeanmichel Cote
  • 531
  • 1
  • 5
  • 19

1 Answers1

2

This is an extremely interesting question, very chicken and egg, how can you measure the height of something that needs to be rendered in order to measure it, without the user seeing it flash up first. Poor solutions to this may involve rendering something off screen etc and measuring it, but that doesn't sit well with the concepts React promotes.

It can however be solved by understanding not only the component lifecycle, but how that fits in respect to the browser lifecycle.

Key to this are componentDidMount and componentDidUpdate. A common misconception is that these occur after the component has been rendered to the browser, not true, it fires just before that happens

render will first reconcile the virtual DOM with anything you have asked to render (another misconception here - the virtual DOM is not actually a real or even shadow DOM, it is just a tree of JS objects representating the components and their DOM that the algorithm can derive diffs from - you cannot ascertain dynamic heights or interact with real DOM elements at this point).

If it senses that there are actual DOM mutations to be done then componentDidMount/componentDidUpdate now fires BEFORE actual browser rendering.

It will then perform the derived updates to the real DOM (effectively rendering it to the browser DOM). Crucially though the browser lifecycle means that this rendered DOM does not yet get painted to the screen (ie it is not rendered from the user's perspective), until the next tick when requestAnimationFrame has had a chance to intercept it...

componentWillReceiveProps
 |
 V
shouldComponentUpdate
 |
 V
componentWillUpdate
 |
 V
render
 |
 V
componentDidUpdate
 |
 V
[BROWSER DOM rendered - can be interacted with programatically]
 |
 V
requestAnimationFrame callback
 |
 V
[BROWSER screen painted with updated DOM - can be seen and interacted by user]

So at this point in componentDidX we have the opportunity to tell it to intercept the browser BEFORE it paints and yet we can interact with the real updated DOM and perform measurements! To do this we need to use window.requestAnimationFrame which takes a callback function in which we can perform a measurement. As this is an async function it returns an id that you can use to cancel it much like setTimeout does (and we should remember to clean up any pending requests so from componentWillUnmount).

We will also need a measurable parent element that does not get styles set on it, with a ref stored so we can get at the DOM node to measure it. And a wrapper that will be animated in height.

Simplified without react-motion (but the outer div can be wrapped in a Motion and can plug the interpolation in to the dynamic style prop)...

  // added type info for better clarity

  constructor(props: SomeProps) {
    super(props)
    this.state = {
      height: undefined
    }
  }

  listRef: HTMLElement

  bindListRef = (el: HTMLElement): void => {
    this.listRef = el
  }

  rafId: number

  interceptAndMeasure = (): void {
    // use request animation frame to intercept and measure new DOM before the user sees it
    this.rafId = window.requestAnimationFrame(this.onRaf)
  }

  onRaf = (): void => {
    const height: number = this.listRef.getBoundingClientRect().height
    // if we store this in the state - the screen will not get painted, as a new complete render lifecycle will ensue
    // BEWARE you must have a guard condition against setting state unnecessarily here
    // as it is triggered from within `componentDidX` which can if unchecked cause infinite re-render loops!
    if (height !== this.state.height) this.setState({height})
  }

  render() {
    const {data} = this.props
    const {height} = this.state.height
    // the ul will be pushed out to the height of its children, and we store a ref that we can measure it by
    // the wrapper div can have its height dynamically set to the measured value (or an interpolated interim animated value!) 
    return (
      <div style={height !== undefined ? {height: `${height}px`} : {}}>
        <ul ref={this.bindListRef}>
          {data.map((_, i) => 
            <li key={i}>
              {_.someMassiveText}
            </li>
          )}
        </ul>
      </div>
    )
  }

  componentDidMount() {
    this.interceptAndMeasure()
  }

  componentDidUpdate() {
    this.interceptAndMeasure()
  }

  componentWillUnmount() {
    // clean up in case unmounted while still pending
    window.cancelAnimationFrame(this.rafId)
  }

Of course this has been encountered before and as luck would have it you can skip having to worry about implementing it yourself and use the excellent react-collapse package (https://github.com/nkbt/react-collapse) which uses react-motion beneath the surface to do exactly this, and also handles many edge cases like nesting dynamic height etc

But before you do, I really urge you to at least give this a try yourself as it opens up a much deeper understanding of both the React and browser lifecycle, and will help with deeply interactive experiences you would have a hard time achieving otherwise

alechill
  • 4,274
  • 18
  • 23
  • 1
    By the way I would advise against passing what I presume is a UUID as a component key (`key={v4()}`), as it will differ on each render pass, causing the ListItem component to be considered as a new component each time, and could cause child state to be lost and bugs further down the component tree – alechill Jul 18 '17 at 19:50
  • Thanks for helping out! It looks pretty interesting but here, the basic lifecycle flow is: will mount -> render -> did mount -> will receive props -> should update, etc. Even at 'will mount', we can't seem to be able to get any ref set. what do you mean by 'If it senses that there are actual DOM mutations to be done'? 'Senses' sounds extremely vague. How do you make it 'sense'? – Jeanmichel Cote Jul 19 '17 at 17:56
  • Ok so sense was a bad word to use. If it has determined that a real DOM update should be made by diffing the virtual DOM tree with the previous and recognising there is a difference. You don't need to do anything special here, it's just as normal by simply having something changed in your render so this I'm sure you understand already, I was just trying to explain the order of life cycle a bit more in sequence, apologies for confusion! – alechill Jul 19 '17 at 19:13
  • Yep you won't be able to access a ref until the real DOM has been created (as it is a pointer to a real node that you can then measure) which can only happen after componentDidMount... at willMount no DOM exists at all. Your component will of course go through extra life cycle after mounting due to change to props, state, or of course animation a itself, which is why you should measure via requestAnimationFrame from both the componentDidMount and componentDidUpdate methods in the same manner, in case more children are added to your list later on, it will work out the height again – alechill Jul 19 '17 at 19:17
  • So I've tried many variations on the exemple code that you offered here but none worked. Probably my fault. Then, I did install `react-collapsed`, though, and it worked like a charm. Very interesting to look into the source file. Pretty clever... – Jeanmichel Cote Jul 20 '17 at 13:55
  • If you were having problems with a ref, it could be what the ref was being applied to - it is a DOM node ref if on a standard element component, but if applied to your custom List component it would be a ref to the List instance. In that case you would need a wrapper to get a ref to the DOM node, or add a ref to the node inside your List component and expose it by a public getter. Glad its sorted, I'd suggest react-collapse was used anyway, as you're using react-motion already so adds hardly any weight to the app, and handles edge cases, but is good to know the mechanics of it by trying first – alechill Jul 20 '17 at 15:04
  • You are right about the ref not working when an attribute of a React component. That is one of the things that I did, wrapping my list component in a simple `
    ` and attaching to ref to it.
    – Jeanmichel Cote Jul 20 '17 at 15:59