React-motion: How to animate height , when element heght is not fixed

Created on 14 Jul 2015  路  48Comments  路  Source: chenglou/react-motion

Such as, We need an collapse component. An collapse has some panel, every panel has a header element.

When header clicked, then panel open or close. The panel open need animate panel heigth for zero to panel's scrollHeight. Close action reverse.

The problem is, I can only get the hight(el.scrollHeight) of panel after the panel render. React-motion require the endState height, before the panel render.

question

Most helpful comment

guys, you're doing it wrong! All you need is a wrapping div. You set and animate height on the wrapper, meanwhile the content block always have height: auto so you may always know what the _true_ height is. You cut it to the wrapper height by having overflow: hidden on the wrapper. And that's it!

All 48 comments

subscribing. :+1:

@chenglou ideas?

Hey! I think this isn't a problem specific to react-motion. EDIT: definitely specific to react-motion because endValue will be called even on the first render. @chenglou maybe we should render once before calling endValue.

If you need the element's height before rending but you can only get it after at least one render, then you're going to have to set some default height. Then you can hook up an event handler that calls this.setState({height: ...}) every time the event you need fires (here I'm guessing scroll?).
If you need to access a DOM element renderer by react you should look into using refs.

In any case, only the first render will need some default value.

yeah there is no real way to solve this problem without actual rendering. It is not possible with any tools (not only react-motion). With css transitions you cannot animate height:auto either.
So it is possible to render element in the same container (in wrapper so it is invisible), get it's dom node height, then set wrapper state and only after that do animation knowing actual element height.

It is just the nature of "fluid" html rendering, you don't know the height until element is rendered.

Maybe there could be some trick provided by browser renderer and exposed to js, but I am not aware of any existing at the moment.

We could provide a helper/library in the future. Not in core, probably.

In this repo, the earlier commits show an example with css-layout. It worked really well. Glorious, glorious layout transitions. Didn't have time to put them into a respectable demo for the talk.

Leaving this open because yes, this is unfortunately an issue on the web and I want to provide a good default.

I need that wrapper as well, so in next couple of days will make it. I made
it for other frameworks couple of times before. Just need that stuff done
for react now.

Gonna be fun

The lesson we've learned from TransitionGroup is that while it handles its specific use-cases very well, those use-cases aren't enough. So here I'm going down a level of abstraction and provide means to diff on data itself rather than final components (which is less ideal than the latter, but much more practical until we find the right abstraction for diffing on components).

One neat thing we did before was that, since TransitionSpring diffs on the keys of your endValue, You can turn your css-layout output into that flat format, diff it, then "re-hydrate" it, so that at the end you get to diff on arbitrary layout structures. It was painful without helper methods though, but somewhat intriguing.

(TransitionSpring doesn't do nested diffs. Been there done that. Way too much boilerplate needed.)

Since Spring lets us update an animation mid flow smoothly, how about this:

  1. User clicks panel header to expand it
  2. Spring starts to animate to an 'estimated' default height, say 100px
  3. Contents are rendered and 'real' height is calculated
  4. Spring updates animation mid flow to the new 'real' height (say 150px)
  5. Since all this happens in, say, 100ms, the animation still looks clean to the user and there is no jumping

@nick that's what I meant when I said "use a default value for the first render" :D

guys, you're doing it wrong! All you need is a wrapping div. You set and animate height on the wrapper, meanwhile the content block always have height: auto so you may always know what the _true_ height is. You cut it to the wrapper height by having overflow: hidden on the wrapper. And that's it!

@constb care to show a snippet? Say you're animating a box that's as high as the text inside it, from that height to 0.

@constb wrapper is definitely needed if we don't want to mess up the content, but when we don't know the content height, we can't animate it. We first need to render content, get it's height and then do animation.

I'm not familiar with react-motion api yet, so I made a simple demo in pure js. http://jsfiddle.net/constb/xur4pf6m/1/ I animate wrapper height that always has height of 0 or auto when not animating. Content lives inside and provides code with height value in pixels. Does that answer the question?

@constb

 wrapper.style.height = state ? content.offsetHeight + 'px' : 0;

You get the content.offsetHeight to do animation. You need to know height of the content to do it. We are talking about animating element which is not yet rendered.

@constb try to start with var state = false; instead ;)

Also try to do it when element is inserted dynamically to the page. So when you insert element, you want it to expand and not just appear instantly

@nkbt easy. http://jsfiddle.net/constb/xur4pf6m/2/ my point is - if you need to know content height - render it into a wrapper with height: 0; overflow: hidden; and then animate wrapper's height. don't animate content height.

@constb this is exactly what I suggested before =)

@nkbt sorry, my bad. )

@constb no problem, i completely forgot about this issue for a while, since it is not assigned to me. Oh, I wish we can "assign to myself" in Github for not owned repos...

What's the best way for grabbing the height of the wrapper?

Not sure best where to store off/update state since that should be avoided in rendering, but I can't access the ref until after the initial render anyway.

@derekr check out this PR https://github.com/elementalui/elemental/pull/31/files for reference.
You will need to check the element height and margins.

Awesome! Thanks @nkbt

It works well to get the height (post-render) and then animate from 0 to that height. One hiccup along the way is when the content that has just rendered has images鈥攖hese images might not fully load for a second or two, causing a div to be cut short and showing all content (becuase the images that eventually render take up extra height, sometimes). To make it work, one solution is having some sort of listener for loaded images...and having that trigger the state change, which would then trigger the animation.

The layout could change due to elements other than images, too.

The best would be to handle the elementresize event via its polyfill (there
is one based on overflow events, without polling the DOM for size changes,
sorry cannot provide the link now).

@sikhote Yeah! I stumbled upon this one before: https://github.com/sdecima/javascript-detect-element-resize (inspired by http://www.backalleycoder.com/2013/03/14/oft-overlooked-overflow-and-underflow-events/ ).

out of interest https://fabric.io/blog/introducing-the-velocityreact-library does a render a the 'completed' state to get the height before it starts it's animation.

Don't they have a flicker problem?

Using Springs, I don't see a flicker when I animate the height of a container when checking if the height has changed (by setting the height to auto and reverting back) on componentDidMount and componentDidUpdate.

@chenglou no can't see any flicker.

can react-measure be used for this?

Just published https://www.npmjs.com/package/react-collapse
Issue can be closed I guess

I did not have time to fill README, will do it tomorrow. See example for reference. It is as simple as:

<Collapse isOpened={isOpened} style={style.container}>
  <div style={{style.content}}>Content</div>
  <div style={{textAlign: 'center', padding: 10}}>Hello!</div>
</Collapse>

Supports multiple children. Support dynamic height. Unmounts collapsed children.

Still kinda want to offer this out of the box if possible, though

I think it is better not to include it into a react-motion itself. It is a not-so-pure component.
Better move it under react-motion org (when we have one) and reference from readme

thanks @nkbt this is exactly what i was looking for ;)

@arush :+1:

Ok so i spent some time using both velocity-react and react-motion + react-collapse, and I have to say, I agree with @chenglou that animation to/from height:auto out-the-box is something I'd like to see in RM core. Honestly im finding myself wanting to use velocity-react for these situations just because the API is simpler.

Height animation is one of the most important features for most TransitionMotion use cases - if you ever want something to enter/leave without the container-div to do that awful snap after the node is removed - the height will need to be animated to/from auto. This is a very common use case for menus, accordions, dropdowns, popovers/tooltips. Whether this is something that RM should deal with or simply pass off to Velocity.js (someday) idk, but the API should be simple for the developer.

@arush can you raise an issue in react-collapse for what you find frustrating or what is not working for you? That would be really awesome.

@chenglou in my experience, velocity-react does have a flicker problem, and doesn't deal with this situation easily either... here's the issue with some gifs: https://github.com/twitter-fabric/velocity-react/issues/36

in the end, velocity-react doesn't cater for auto height children elegantly without opacity and display hacks.

@nkbt Gonna look into react-collapse again.

Stumbled upon this by looking for a way to collapse heights. Don't know if this will help or if it has already been solved by nkbt but you can collapse it while caring about the container's height with negative margin-top equal to the outerHeight(true) of the element in transition: http://codepen.io/corysimmons/pen/qbeyZz

+1

I believe my initial comments above are out of date re: velocity-react. I am using their native slide methods to handle this pretty well now

There is no CSS transition to from height: 0; to height: auto;.

A _(not so well-known)_ workaround to solving this problem is to, instead, animate maxHeight from 0 to a ridiculously high number. That way the items will grow from 0 to their actual height.

    getDefaultStyles() {
        return this.props.items.map(item => ({
            key: item.id,
            style: {
                maxHeight: 0,
                opacity: 1,
            },
            data: {
                ...item,
            },
        }));
    }

    getStyles() {
        return this.props.items.map(item => ({
            key: item.id,
            style: {
                maxHeight: spring(10000, presets.noWobble),
                opacity: spring(1, presets.noWobble),
            },
            data: item,
        }));
    }

    willEnter() {
        return {
            maxHeight: 0,
            opacity: 1,
        };
    }

    willLeave() {
        return {
            maxHeight: spring(0),
            opacity: spring(0),
        };
    }

Fair warning though: do note that this kind of animation will perform very badly as it will trigger layout.

Regards,
Bramus.

I'm a little late to this party. I spent hours researching this and trying to find a solution, including anything that react-motion can do for me.

I think this is what @constb is talking about or something similar. It is the approach I took. Nice, light, and all native.

This is the my Accordion content component:

import React, { Component } from 'react';

class Content extends Component {
  constructor (props) {
    super(props);

    this.state = {
      isActive: this.props.isActive,
      styles: {
        height: 0,
        overflow: 'hidden',
        transition: '250ms ease-in-out'
      }
    };
  }

  componentWillReceiveProps (nextProps) {
    this.setState({
      isActive: nextProps.isActive,
      styles: {
        height: nextProps.isActive ? `${this.refs.content.clientHeight}px` : 0,
        overflow: nextProps.isActive ? 'none' : 'hidden',
        transition: '250ms ease-in-out'
      }
    });
  }

  render () {
    return (
      <div className="Accordion-container" style={this.state.styles} ref="container">
        <div className="Accordion-content" ref="content">
          {this.props.children}
        </div>
      </div>
    );
  }
}

export default Content;

@chrispeterson3 The above solution works nicely but it hardcodes the initial state to be always closed. I have modified it a bit to accommodate both open and closed initial states.

JS

/**
 * This is an animated expandable and collapsible component. It will open and
 *    close based on the 'isOpen' prop with a nice animation.
 *
 * LOGIC
 * Render the element with an initial height of 'auto' so that we can
 *    calculate it's actual height after mounting is completed (actual height is
 *    required as we cannot animate from height: auto -> height: 0 and
 *    vice versa). Then fire 'forceUpdate' to re-render it based on the actual
 *    open/close state.
 */
import React, { Component, PropTypes } from 'react';

class ExpandCollapse extends Component {
    componentDidMount() {
        this.containerHeight = `${this._container.clientHeight}px`;
        this.forceUpdate();
    }

    render() {
        // this will be undefined for first render
        let newHeight = this.containerHeight;

        if (newHeight !== undefined) {
            newHeight = this.props.isOpen ? this.containerHeight : 0;
        } else {
            newHeight = 'auto';
        }

        return (
            <div
                className="expand-collapse"
                style={{height: newHeight}}
                ref={(input) => {this._container = input;}}
            >
                {this.props.children}
            </div>
        );
    }
}

/**
 * [propTypes description]
 * @type {Object}
 * @property {bool} isOpen   - true if item is open, false if closed.
 * @property {node} children - the item content.
 */
ExpandCollapse.propTypes = {
    isOpen: PropTypes.bool.isRequired,
    children: PropTypes.node.isRequired
};

export default ExpandCollapse;

CSS

.expand-collapse {
    overflow: hidden;
    transition: height .3s ease-in;
}

I am doing something like this on my Open component, any feedback would be much appreciated. The issue I have now is how do we animate from auto to an updated value? Assume the height of the children dynamically changes.

Also for a slight performance boost, I am removing the nodes from the DOM when the components if props is set to false .

TSX

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as classNames from 'classnames';
import './Open.less';

export interface IOpenProps {
  if? : boolean;
  className? : any;
  children? : any;
  overflow? : boolean;
  parent? : boolean;
  child? : boolean;
  fill? : boolean;
}

class Open extends React.Component<IOpenProps, any>{

  public refs : any;

  constructor(props){
    super(); 

    this.state = {
      height : props.if ? 'auto' : 0,
      open: props.if || false,
      render: props.if || false
    }
  }

  componentWillReceiveProps(nextProps){
    this.setState({
      open: nextProps.if
    }, ()=>{
      nextProps.if ? this.setHeightOpen() : this.setHeightClose();
    })
  }

  setHeightOpen(){
    const self = this;
    if (this.refs.Open) {
      this.setState({
        height : this.state.open ? this.refs.Open.children[0].clientHeight : 0,
        render: true
      }, ()=>{
          setTimeout(() => {
              self.setAuto();
          }, 300);
      })
    }
  }

  setHeightClose(){
    const self = this;
    if (!this.state.open) {
      this.setState({
        height : this.state.height === 'auto' ? this.refs.Open.children[0].clientHeight : 0
      }, ()=>{
        if (this.state.render) {
          setTimeout(()=>{
            self.setState({
              height : 0,
              render: false
            })
          }, 0);
        }
      })
    }
  }

  setAuto() {
    if(this.refs.Open && this.state.height > 0) {
      this.setState({
        height : 'auto'
      })
    }
  }
  render(){
     const self = this;
     const props = self.props;
     const state = self.state;

     let OpenClass = classNames(
        'r-Open',
        {'e-open': (props.if)},
        {'e-close': (!props.if)},
        {'parent': (props.parent)},
        {'child': (props.child)},
        {'fill': (props.fill)},
        props.className
     );

     return(
       <div ref="Open" className={OpenClass} style={{height : state.height}}>
         <div>{state.render || state.open ? props.children : null}</div>
       </div>
     )
  }
}

export default Open;

CSS

@import '../../less/Global';

.r-Open{
  position:relative;
  -webkit-transform-origin: top;
    -o-transform-origin: top;
    -ms-transform-origin: top;
    transform-origin: top;
  -webkit-transform: scaleY(1);
  -o-transform: scaleY(1);
  -ms-transform: scaleY(1);
  transform: scaleY(1);
  -webkit-transition: -webkit-transform 0.26s ease-out;
  -o-transition: -o-transform 0.26s ease;
  -ms-transition: -ms-transform 0.26s ease;
  transition: all 0.26s ease;
  height: auto;
  overflow: hidden !important;
}

.r-Open.e-autoHeight{
  height : auto !important;
  max-height : none !important;
}

.r-Open.e-close{
  height: 0;
  -webkit-transform: scaleY(0);
    -o-transform: scaleY(0);
    -ms-transform: scaleY(0);
    transform: scaleY(0);
}

Sorry to hijack the thread but this seems to be the only discussion on this issue. I've just started playing around with react-motion and it would appear that I've started of attempted one of the most tricky use cases.

Effectively, I'm trying to do the equivalent of this in react-motion:

import React, { Children, Component } from 'react';

const transition = 'height 0.75s';
const SCROLL_TIMEOUT = 2000;

export default class Scroller extends Component {
  componentDidMount() {
    setTimeout(this.scroll.bind(this), SCROLL_TIMEOUT);
  }

  scroll() {
    const parent = this.scrolledContainer;

    if (parent) {
      if (parent.children.length < this.props.scrollAfter) return;

      const child = parent.children[0];

      child.addEventListener('transitionend', () => {
        child.style.height = 'auto';
        child.style.transition = '';

        if (parent) {
          parent.removeChild(child);
          parent.appendChild(child);
        }

        setTimeout(this.scroll.bind(this), SCROLL_TIMEOUT);
      }, { once: true });

      child.style.overflow = 'hidden';
      child.style.transition = '';

      requestAnimationFrame(() => {
        child.style.height = `${child.scrollHeight}px`;
        requestAnimationFrame(() => {
          child.style.transition = transition;
          child.style.height = 0;
        });
      });
    }
  }
  render() {
    return (
      <div ref={(e) => { this.scrolledContainer = e; }}>
        {Children.map(this.props.children, c => <div is data-scrollable key={c.key}>{c}</div>)}
      </div>
    );
  }
}

Given a list of items it reduces the height of the firs one, giving the appearance of scrolling up. It then removes it and sticks it at the bottom of the list and repeats the process with the next one and so on indefinitely.

I'm looking to use react-motion in order to avoid manipulating the DOM directly and having this discrepancy between the order of children props and the actual order in the DOM. Initially it seemed like TransitionMotion would be the tool for the job but I've had no success unless I hard-code the height of the elements.

So is this type of thing possible in react-motion or is it simply not designed for this type of use case?

I'm new to react and I'm trying to wrap my head around how I will accomplish certain things before I jump in because I know they are going to come up sooner rather than later. This is one of the things I deal with regularly. I dont really know enough about react to know if my idea is plausible so I'll just try to explain it here and see if you guys think there is away this could be implemented into RM.

To start you have to create a window.resize event listener that basically loops through all elements with a class such as slide-container then stores their computed height and element reference in an array of objects, then initializes each element. This needs to be called once upon page load and then any time the viewport is resized as shown here:

In jQuery this looks something like this:

var sliders = [];
$(function() {
  $(window).resize(function() {
     sliders = []; // reset to blank
     $('.slide-container').each(function() {
      if($(this).hasClass('opened') {
        /* Already opened so the height is accurate as is no prep required */
        sliders.push({element: this, height: $(this).outerHeight()});
      } else {
        /* Hidden, so we have remove it from the document flow, way off screen set the height to auto,
            grab the height and then put it back where it was */
        $(this).addClass('prep').css('height', 'auto');
        sliders.push({element: this, height: $(this).outerHeight()});
        $(this).removeClass('prep').css('height', 0);
      }
   }).resize();  /** This calls the resize function on page load to ensure the initial values are captured) **/
});

The CSS:

.slide-container {
  position: static;
  overflow: hidden;
  height: 0;
  transition: height .5s ease;
}

/* attaching this class removes it from the document flow so the height can be calculated without showing it to the user */
.slide-container.prep {
  position: absolute;
  top: -50000px;
}

The css for the containers is pretty basic, just setting up the transition property and height: 0 and overflow: hidden so that nothing is shown before the configuration function above can run. The prep class is what removes the non-open containers from the document flow so that the height can be calculated.

The javascript needs to always control the height because of responsive changes to the viewport. You cannot just set the heights onload and forget it because of the viewport width changes the content height may increase which would break a static height value.

In the action method where the link is clicked or hovered where the animation needs to happen the target div would need to have its stored 'opened height' dynamically set via javascript causing the height animation to play out due to the transition property. A class 'opened' would have to be added so the window resize function knows how to handle it.

Also the code that sets the height would have to set the the css height to auto once the animation completes so that if the window is resized the content height is flexible. Is this an issue due to the way the rendering of elements work in react? Can you change the css on a setTimeout or something after the animation has completed?

The close action would just have to set the height to zero and remove the opened class.

Thoughts?

Check this...

in render method...

<Button
      onPress={() => {
        if(this.state.setContentHeight) {
               this.setState({contentHeight: content.length*25, setContentHeight: false});
        }
        this.toggleContent()
        //toggleContent calls Animated values from state to animate them
      }}
    />
    {this.state.showContent ? this.renderContent() : null}

Works for me. Even though it's really gross. Only one problem. On the FIRST press of the button, the contentHeight has to be initialized to a random number. Which is okay for me because my drop downs will only show a maximum amount of elements.

EDIT:
Fixed that issue with a calllllbaccck!! I'm a boss. Now I just initialize "contentMaxHeight" to 0 in the constructor. Let me know if this works for anyone.

if(this.state.setContentMaxHeight) {
     this.setState({contentMaxHeight: content.length*40, setContentMaxHeight: false}, () => {
        this.toggleContent();
     });
} else {
      this.toggleContent();
}
Was this page helpful?
0 / 5 - 0 ratings