React: How to Animate Expanding and Collapsing Div When the Size of the Content is Not Knowable

15,187

I think if you use an animation library it is much easier. I refactored your code to use react-spring. The code is much cleaner this way.

ListItem component:

const ListItem = ({ item, depth }) => {
  //   depth prop allows me to give a className depending on  how deeply it is nested, and do CSS style based on that
  let { text, children } = item;
  let [showChildren, setShowChildren] = React.useState(false);
  const transition = useTransition(
    showChildren ? children : [],
    item => item.text,
    {
      from: { opacity: 0, transform: 'scaleY(0)', maxHeight: '0px' },
      enter: { opacity: 1, transform: 'scaleY(1)', maxHeight: '1000px' },
      leave: { opacity: 0, transform: 'scaleY(0)', maxHeight: '0px' }
    }
  );

  return (
    <ol>
      <button
        onClick={e => {
          item.children && setShowChildren(!showChildren);
        }}
      >
        {text}-{depth}
        {(children && <i className="fas fa-sort-down" />) ||
          (children && <i className="fas fa-sort-up" />)}
      </button>

      <div className={`frame depth-${depth}`}>
        {transition.map(({ item, key, props }) => (
          <animated.li key={key} style={props}>
            <ListItem item={item} depth={depth + 1} />
          </animated.li>
        ))}
      </div>
    </ol>
  );
};

A little explanation: You can specify enter and leave styles. Enter called when new nodes added to a list. Leave called when it removed. And all styles are animated. One problem remained, if I set the maxHeight value too high the height animation is too quick. If I set it too low then in could constraint the appareance of the list element with too much children. The 1000px is a compromise and it works with this example. It could be solved either with animating other style property or calculating a maxHeight value with a recursive function for each node.

Working example: https://codesandbox.io/s/animated-hierarchical-list-5hjhz

Share:
15,187

Related videos on Youtube

Maiya
Author by

Maiya

Updated on June 16, 2022

Comments

  • Maiya
    Maiya almost 2 years

    Question:

    I have a React functional component that recursively renders nested lists. That part is working fine.

    The part that I am struggling with is getting the divs that contain the nested lists to have an expanding animation when the nested lists (of varying sizes) appear and disappear.

    Because the amount of content is not known, simply animating the max-height property does not work. For example, when one level of lists is rendered, the height of the frame might expand to 100px. However, if you animate the max-height to 100px, then the div can not expand later on to accommodate more and more nested lists that get expanded.

    The div that needs to be animated is:

    <div className="frame"> [nested ordered lists] </div>
    

    ... and the function that is not working is the function named "collapseFrame."

    Again, I previously tried using a CSS transition on the max-height property but that is much less than an ideal solution, because it does not work well on unpredictable heights. So, I don't want to do it that way, if at all possible.

    I followed a tutorial and got it working with vanilla JavaScript, without React (parts of the code that are commented out), but when translating it to React, I am not sure why I can not get the code to work.

    At the moment, the expanding animation is working, but it will stop working if the border of the is removed or changed to the color white. I don't know why. ??

    ** Also, the collapsing animation is not working at all. The 'transitioned' event does not always fire on the ref object, and sometimes fires after it is supposed to have been removed.

    Is anyone able to help point me in the right direction? Thanks!

    This is my codepen.

    (I tried to transfer it to JS Fiddle, but it's not working, so pls don't down-vote for that).

    https://codepen.io/maiya-public/pen/ZEzoqjW

    (another attempt on codepen, for reference. The nested lists are appearing and disappearing, but not transitioning). https://codepen.io/maiya-public/pen/MWgGzBE

    And here is the raw code: (I think it is easier to understand on the codepen, but pasting here for good practice).

    index.html

    <div id="root">
    </div>
    

    style.css

    .frame {
        overflow:hidden;
        transition: all 0.5s ease-out;
        height:auto;
    //  for some reason,without the border, not all of them will transition AND it can't be white !?? 
        border: solid purple 1px; 
    }
    
    button {
       margin: 0.25rem;
    }
    

    Dummy data: (the nested lists will render based on this object)

    let data = { 
     text: 'list',
      children: [        
      {
    text: "groceries",
    children: [
      {
        text: "sandwich",
        children: [
          {
            text: "peanut butter",
            children: [{text: 'peanuts', children: [{text: 'nut family'},{text: 'plant', children: [{text: 'earth'}]}] }]
          },
          {
            text: "jelly",
            children: [
              { text: "strawberries", children: null },
              { text: "sugar", children: null }
            ]
          }
        ]
      }
    ]
      },
        {
        text: "flowers",
        children: [
          {
            text: "long stems",
            children: [
              {
                text: "daisies",
                children: null
              },
              {
                text: "roses",
                children: [
                  { text: "pink", children: null },
                  { text: "red", children: null }
                ]
              }
            ]
          }
        ]
      }
    ] };
    

    React code: index.js

    // component recursively renders nested lists.  Every list item is a list.
    const ListItem = ({item, depth}) => {
    //   depth prop allows me to give a className depending on  how deeply it is nested, and do CSS style based on that
      let { text, children } = item
      let [showChildren, setShowChildren] = React.useState(false)
      let frame = React.useRef()
    
      const expandFrame = (frame) => {
          //let frameHeight = frame.style.height //was using this at one point, but not anymore b/c not working
          // was supposed to have frame.style.height = frameHeight + 'px'
          frame.style.height = 'auto'
          frame.addEventListener('transitionend', () =>{
            frame.removeEventListener('transitionend', arguments.callee)
            frame.style.height = null
         })
      }
    
      const collapseFrame = (frame) => {
       let frameHeight = frame.scrollHeight;
    
     // temporarily disable all css transitions
      let frameTransition = frame.style.transition;
      frame.style.transition = ''
    
      requestAnimationFrame(function() {
        frame.style.height = frameHeight + 'px';
        frame.style.transition = frameTransition;
        requestAnimationFrame(function() {
          frame.style.height = 0 + 'px';
        })
        })
          }
    
      return(
    <ol> 
          <button onClick={(e)=>{
              if(!showChildren) {
                  // console.log('children not showing/ expand')
                 setShowChildren(true)
                expandFrame(frame.current)
              } else {
                // console.log('children showing/ collapse')
                 setShowChildren(false)
                 collapseFrame(frame.current)
              }
            }}
            >
            {text}-{depth}  
            {  children && <i className="fas fa-sort-down"></i> || children && <i className="fas fa-sort-up"></i>} 
        </button>
          
      {/*THIS IS THE ELEMENT BEING ANIMATED:*/}
      <div className={`frame depth-${depth}`} ref={frame}>
        
            {showChildren && children && children.map(item => {
              return (
                <li key={uuid()}>
                    <ListItem item={item} depth={depth + 1}/>
                </li>)
              })
          }
        
        </div>
     </ol>
      )
    }
    
    class App extends React.Component {
    
    
      render() {
        return (
          <div>
             <ListItem key={uuid()} item={data} depth={0} />
         </div>
        );
      }
    }
    
    function render() {
      ReactDOM.render(<App />, document.getElementById("root"));
    }
    
    render();
    
  • Maiya
    Maiya over 4 years
    Hi Peter - Thank you for posting this! It is super helpful to see how the react-spring library works. I re-worded my question, to point out that it is the div that is surrounding the list items that is not animating (not the lists items themselves), and that the problem is that you can't use maxHeight when there is no way to predict the height. That being said, maybe moving the animation to the list items is the most practical work-around. Thanks for the tips!
  • Peter Ambruzs
    Peter Ambruzs over 4 years
    If the data is known, then you can calculate the number of all children for a node with a relatively easy way. If you want to include the open/close state in the calculation. You can lift the open state from the items to the list. But you can leave out this info and for max-height it is still relatively good.
  • Maiya
    Maiya over 4 years
    ok .. and i realized that moving it to the li tags won't make a difference b/c they have to expand just as much as the frame! I looked around on React Spring and saw an example where they were doing some things, so I'll study that. Thanks again!
  • Learner
    Learner almost 3 years
    Without using a package is there a way, still now also i didn't find a proper solution without using a package
  • Biaspoint
    Biaspoint about 2 years
    Updating to react-spring 9.4.2 breaks the codesandbox... with this line: item => item.text,