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:
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
- Animate the Add Process
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.
- Animate the Removal Process
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.
Alex Lazar
Related Posts
-
React Navigation and Redux in React Native Applications
In React Native, the question of “how am I going to navigate from one screen…
-
Using ES2016 Decorators in React Native
*picture courtesy of pixabayDecorators are a popular bit of functionality currently in Stage 1 of…