Dynamic Animated Lists in React Native

   JavaScript
React Native Dynamic Animated Lists featured

Would you like to add some visual sugar to your dynamic lists in React Native? Do you want to create a pleasant visual experience for users of your application? Here’s a tutorial that will give you a step by step approach to creating your own dynamic animated ListView in React Native.

Here’s an animated gif to illustrate what I mean by an animated ListView:

React Native Dynamic Animated Lists exampls



Every time you want to add or remove something from your list to have the option to apply an animation transition to your elements.

Why could it be more complicated than you initially think in React Native?

The ListView Data Source needs to be updated in order to display the new data for whatever operations you want to perform on it. The problem is that the ListView RN component doesn’t give you the ability to animate this process; it just happens and the data re-renders in the blink of an eye.

You can check the complete source code in our Github repo and run this demo. Read on as we take a look at how it works.

Animating the ListView: Adding and Removing Items

  1. Animate the Add Process
  2. Let’s try to add an item by creating a fade-in animation.

    Consider the following component that contains a ListView that will load initial data from a JSON file. We want to add more items to its data source and animate that.

    This is the default component with the render() method:

    export default class DynamicList extends Component {
    
       state = {
           loading     : true,
           dataSource  : new ListView.DataSource({
               rowHasChanged : (row1, row2) => true
           }),
           refreshing  : false
       };
       // … omitting parts of the code to keep it short and have the main methods only
       render() {
           return (
               <View style={styles.container}>
                     <ListView
                       dataSource={this.state.dataSource}
                       renderRow={this._renderRow.bind(this)}
                   />
               </View>
           );
       }

    Here is the renderRow() method for the list view

       _renderRow(rowData, sectionID, rowID) {
           return (
               <DynamicListRow>
                    … Views defined in between ...
               </DynamicListRow>
           );
       }
    
    }

    Now, let’s define the DynamicListRow component and see what we need to have in order to implement an animated added list item:

    class DynamicListRow extends Component {
    
       _defaultTransition  = 250;
    
       state = {
           _rowOpacity : new Animated.Value(0)
       };
    
       componentDidMount() {
           Animated.timing(this.state._rowOpacity, {
               toValue  : 1,
               duration : this._defaultTransition
           }).start()
       }
    
       render() {
           return (
               <Animated.View
                   style={{opacity: this.state._rowOpacity}}>
                   {this.props.children}
               </Animated.View>
           );
       }
    
    }

    As you can see here, we have defined the initial row opacity value in the state. When the component is mounted, componentDidMount is fired as well as the animation using the Animated React Native component.

    So far so good. Cheers! We have succeeded in adding an item and animating this process.

  3. Animate the Removal Process
  4. Let’s say we want to remove an item and we want to animate the height. This is the most common behavior used for removing items from a ListView.

    This is a little more complicated than adding an item to the list. If you add the same animation in the same way as on componentDidMount(for addition), on componentWillUnmount(for removal) this will not work. This is because ListView removes the list item and the animation does not have time to take place. Animation is an asynchronous activity, so when the dataSource updates, the item is cleared from the list directly.

    How can we work around this?

    Here’s some code and comments to explain the whole process.

    export default class DynamicList extends Component {
       /* Default state values */
       state = {
           loading     : true,
           dataSource  : new ListView.DataSource({
               rowHasChanged : (row1, row2) => true /*  It is important for this to be 
    always true so that the renderRow fires every time we update the dataSource and we set 
    the state without needing to have different items added/removed or updated to the list 
    view */
           }),
           refreshing  : false,
           rowToDelete : null /* this will keep the id of the item to track if will need 
    to be animated and removed afterwards */
       };
    /* Loading data after interactions are done will ensure that the transitions and other 
    activity regarding view rendering has already happened and this is important especially 
    when this is the first View that renders */
    
       componentDidMount() {
           InteractionManager.runAfterInteractions(() =>  this._loadData() ); 
       }
    /* … other methods … */
    
    /* 
    Render a simple ListView. Normally at this point you would have to check on loading 
    state and display a spinner while data loads async from the server, but in this case 
    is not needed as we’re loading everything from a simple local JSON file.
    I have bound the scope of this component to _renderRow method to keep it in this 
    component’s scope and avoid switching the scope to ListView as renderRow is an internal 
    property in ListView.
     */
    render() {
           return (
               
                   <ListView
                        dataSource={this.state.dataSource}
                       renderRow={this._renderRow.bind(this)}
                   />
           );
       }
    /*
    For rendering the list rows we will use DynamicListRow component. 
    The remove property will be responsible for firing the collapse animation of the 
    removal process within DynamicListRow component.
    onRemoving  is going to be fired by the component when the animation transition ends 
    and we will attach _onAfterRemovingElement() which is bound in the current scope as well.
     */
    _renderRow(rowData, sectionID, rowID) {
       return (
           <DynamicListRow
               remove={rowData.id === this.state.rowToDelete}
               onRemoving={this._onAfterRemovingElement.bind(this)}
           >
             // …. Nested items ...
           </DynamicListRow>
       );
    }
    
    
    
    
    /* deleteItem() only sets the property rowToDelete on the state to distinguish the 
    one that has to be deleted */
    _deleteItem(id) {
       this.setState({
           rowToDelete : id
       });
    }
    
    /* Setting the state will fire the re-rendering process and componentWillUpdate will 
    fire as well. This function is called on the callback of the animation so that it 
    only updates the dataSource with the cached data when the delete animation is done */
    _onAfterRemovingElement() {
       this.setState({
           rowToDelete : null,
           dataSource  : this.state.dataSource.cloneWithRows(this._data)
       });
    }
    /* When the state is changed componentWillUpdate fires and will update the cached 
    data we need to render on the list view */
    componentWillUpdate(nextProps, nextState) {
       if (nextState.rowToDelete !== null) {
           this._data = this._data.filter((item) => {
               if (item.id !== nextState.rowToDelete) {
                   return item;
               }
           });
       }
    }

The questions now are, why have we updated the cached data on componentWillUpdate and why do we need a separate function to actually update the dataSource value? The answer is, because we only need _deleteItem() to simulate the removal process of an item by firing out the height animation on the actual list row component.

Here’s how the list row component looks now:

class DynamicListRow extends Component {
   // these values will need to be fixed either within the component or sent through props
   _defaultHeightValue = 60;
   _defaultTransition  = 500;
   state = {
       _rowHeight  : new Animated.Value(this._defaultHeightValue),
       _rowOpacity : new Animated.Value(0)
   };
   componentDidMount() {
       Animated.timing(this.state._rowOpacity, {
           toValue  : 1,
           duration : this._defaultTransition
       }).start()
   }
   componentWillReceiveProps(nextProps) {
       if (nextProps.remove) {
           this.onRemoving(nextProps.onRemoving);
       } else {
// we need this for iOS because iOS does not reset list row style properties
           this.resetHeight()
       }
   }
   onRemoving(callback) {
       Animated.timing(this.state._rowHeight, {
           toValue  : 0,
           duration : this._defaultTransition
       }).start(callback);
   }
   resetHeight() {
       Animated.timing(this.state._rowHeight, {
           toValue  : this._defaultHeightValue,
           duration : 0
       }).start();
   }
   render() {
       return (
           <Animated.View
               style={{height: this.state._rowHeight, opacity: this.state._rowOpacity}}>
               {this.props.children}
           </Animated.View>
       );
   }
}

On the componentWillReceiveProps() method, we’re checking if the remove property is set as true from the parent component in renderRow. This means the animation should be fired, but we also need to know when the animation ends so that the listview dataSource gets updated and we’re not getting invisible rows inside the list.

On iOS, the list rows that are hidden do not reset on ListView re-rendering process. We need to do this manually. Otherwise, we can see more items disappearing from the list and we do not know why. In fact, they are not deleted at all but set as hidden with the height 0.
These are the 3 methods that are also listed higher in the component and are responsible for handling all that.

/*
This method will fire when the component receives new props or the props change on the 
component, if you check the renderRow method, you will see that there’s a remove 
property that is changing every time the state changes
( remove={rowData.id === this.state.rowToDelete} )
*/
componentWillReceiveProps(nextProps) {
   if (nextProps.remove) {
       this.onRemoving(nextProps.onRemoving);
   } else {
       this.resetHeight()
   }
}

/*
Will animate the removal process, transitioning the height
*/
onRemoving(callback) {
   Animated.timing(this.state._rowHeight, {
       toValue  : 0,
       duration : this._defaultTransition
   }).start(callback);
}
/*
Will reset the row height to the initial value. We need this because when we remove a row, 
its height goes to 0 and when we actually remove it from the dataSource right after and 
will fire re-rendering process, the row component does not “reset” on iOS, only the data 
will change within rows, so we end up by seeing 2 fields go out if we don’t call this.
*/
resetHeight() {
   Animated.timing(this.state._rowHeight, {
       toValue  : this._defaultHeightValue,
       duration : 0
   }).start();
}

And there you have it — a dynamic animated listview!

Final thoughts

In my experience, React Native has proven to be the perfect tool to achieve great native performance cross platform by just writing Javascript and JSX.

Indeed, things can get a little complicated when it comes to designing more complex components. Understanding the React component lifecycles and native rendering process are crucial to designing more complex components. As you dive deeper into it, you’ll find out how easy can it be to achieve what you want.
It is also true that you may have to deal with native code at some point, but that does not make much difference between React Native and Hybrid, because in hybrid development you have the native plugins as well to handle what you need. Creating native modules with bridges for React Native does not differ much from creating native plugins for Cordova, which is a topic worthy of a separate post altogether.


Like What You See?

Got any questions?