This is a step by step tutorial on how to build the ‘Favorite’ feature from Spotify in React Native using Expo.
This is a problem that I had to solve for a client. No matter where I searched, I could not find a third party component or tutorial on how to do it, so I did it myself, and I want to help you guys do it in the future.
Getting Started
you want to install the React Native, Expo, and the React Native dependencies and libraries I am using. I used npm in this instance, but you can use yarn if you want.
npm install --save react-native-swipe-list-view
npm install --save react-native-responsive-screen
First of all, you want to create a two React component. One, where your Favorite Swiper will live and another for the Favorite Modal to appear in. The Favorite Swiper is a list of items (songs) where they can be swiped left or right (left) and favorited. The Favorite Modal is a pop-up that shows one an item has been favorited. For this tutorial, I called my components SpotifyFavoriteExample.js and FavoriteModal.js. I decided on a class base architecture.
import React from 'react';
import { Animated, StyleSheet, Text, View } from 'react-native';
import { SwipeListView, SwipeRow } from 'react-native-swipe-list-view';
import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen';
import { Ionicons } from '@expo/vector-icons';
import FavoriteModal from './FavoriteModal';
export default class SpotifyFavoriteExample extends React.Component {
state = {
favoriteModal: null,
dataSource: [
{
id: 1,
name: 'Song 1',
favorite: false
},
{
id: 2,
name: 'Song 2',
favorite: false
},
{
id: 3,
name: 'Song 3',
favorite: false
}
]
}
constructor(props) {
super(props);
this.rowSwipeAnimatedValues = [];
this.rowSwipeIconsShow = [];
this.rowSwipeIconsHide = [];
this.rowSwipeIconsMove = [];
this.renderItem = this.renderItem.bind(this);
this.favoriteItem = this.favoriteItem.bind(this);
this.onSwipeValueChange = this.onSwipeValueChange.bind(this);
Array(this.state.dataSource.length).fill('').forEach((_, i) => {
this.rowSwipeAnimatedValues[`${i}`] = new Animated.Value(0);
this.rowSwipeIconsShow[`${i}`] = new Animated.Value(0);
this.rowSwipeIconsHide[`${i}`] = new Animated.Value(0);
this.rowSwipeIconsHide[`${i}`] = new Animated.Value(0);
});
}
_keyExtractor(item) {
return item.id.toString();
}
favoriteItem(rowKey, rowMap) {
const { dataSource } = this.state;
const prevIndex = dataSource.findIndex(item => item.id == rowKey);
let newDataSource;
this.state.favoriteModal.setModalVisible(true, !dataSource[prevIndex].favorite);
dataSource[prevIndex].favorite = !dataSource[prevIndex].favorite;
newDataSource = dataSource;
this.setState({
dataSource: newDataSource
});
rowMap[rowKey].closeRow();
}
onSwipeValueChange(swipeData, key) {
if(swipeData.value < -140) {
this.rowSwipeAnimatedValues[key].setValue(Math.abs(100));
this.rowSwipeIconsShow[key].setValue(Math.abs(180));
this.rowSwipeIconsHide[key].setValue(Math.abs(0));
} else {
this.rowSwipeAnimatedValues[key].setValue(Math.abs(0));
this.rowSwipeIconsShow[key].setValue(Math.abs(0));
this.rowSwipeIconsHide[key].setValue(Math.abs(100));
}
this.rowSwipeIconsMove[key].setValue(Math.abs(swipeData.value));
}
renderItem(item, index) {
let like_color = !item.user_liked ? '#17bca5' : '#ff6044';
let animatedStyle, frontAnimatedStyle, backAnimatedStyle, iconAnimatedStyle;
if(this.rowSwipeAnimatedValues[item.index]) {
animatedStyle = {
backgroundColor: this.rowSwipeAnimatedValues[item.index].interpolate({
inputRange: [0, 100],
outputRange: ['gray', like_color]
}),
}
iconAnimatedStyle= {
marginRight: this.rowSwipeIconsMove[item.index].interpolate({
inputRange: [0, 100],
outputRange: [wp(6), wp(15)]
}),
position:'relative',
marginBottom: 30
}
frontAnimatedStyle = {
transform: [
{
scale: this.rowSwipeIconsShow[item.index].interpolate({
inputRange: [45, 90],
outputRange: [0, 1],
extrapolate: 'clamp',
}),
}
]
}
backAnimatedStyle = {
transform: [
{
scale: this.rowSwipeIconsHide[item.index].interpolate({
inputRange: [45, 90],
outputRange: [0, 1],
extrapolate: 'clamp',
}),
}
]
}
}
return (
<SwipeRow
onSwipeValueChange={(swipeData) => this.onSwipeValueChange(swipeData, item.index)}
onRowDidClose={() => this.rowSwipeAnimatedValues[item.index].setValue(0)}
friction={20}
tension={500}
key={item.id}
rightOpenValue={-150}
disableRightSwipe >
<View style={{flex: 1, flexDirection: 'row'}}>
<View style={{ width: 150, backgroundColor: 'white' }}></View>
<Animated.View style={[styles.rowBack, animatedStyle]}>
<Animated.View style={[iconAnimatedStyle]}>
<Animated.View style={[styles.rowBackText, backAnimatedStyle, {position: 'absolute'}]}>
<Ionicons name={'ios-heart-empty'} style={styles.inputIcon} />
</Animated.View>
<Animated.View style={[styles.rowBackText, frontAnimatedStyle, {position: 'absolute'}]}>
<Ionicons name={'ios-heart'} style={styles.inputIcon} />
</Animated.View>
</Animated.View>
</Animated.View>
</View>
<View style={styles.itemContainer}>
<Text>Row </Text>
</View>
</SwipeRow>
);
}
render() {
const { dataSource } = this.state;
return (
<View style={styles.container}>
<SwipeListView
data={dataSource}
keyExtractor={this._keyExtractor}
onRowDidOpen={this.favoriteItem}
renderItem={this.renderItem} >
</SwipeListView>
<FavoriteModal
ref={(el) => { this.state.favoriteModal = el; }} />
</View>
);
}
}
const styles = StyleSheet.create({
container: {
marginTop: 50,
flex: 1,
backgroundColor: '#fff',
alignSelf: 'stretch',
justifyContent: 'center',
},
itemContainer: {
flexDirection: 'row',
backgroundColor: 'white',
padding: 25,
},
rowBack: {
flex: 1,
alignItems: 'center',
flexDirection: 'row-reverse',
backgroundColor: 'gray'
},
rowBackText: {
textAlign: 'center',
color: 'white',
fontSize: 22
},
inputIcon: {
color: 'white',
fontSize: wp('8%'),
},
});
import * as React from 'react';
import { StyleSheet, Text, View, TouchableOpacity, TouchableHighlight, Modal } from 'react-native';
import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen';
import { Ionicons } from '@expo/vector-icons';
export default class FavoriteModal extends React.Component {
state = {
modalVisible: false,
liked: false
};
setModalVisible(visible, liked) {
this.setState({ modalVisible: visible, liked: liked });
}
renderLikeStatus(liked) {
return (
liked ?
<View style={styles.iconContainer}>
<Ionicons name={'ios-heart'} style={styles.inputLikeIcon} />
<Text style={styles.iconText}>Added!</Text>
</View> :
<View style={styles.iconContainer}>
<Ionicons name={'ios-heart'} style={styles.inputDislikeIcon} />
<Text style={styles.iconText}>Removed!</Text>
</View>
);
}
closeAfterSomeTime = (n) =>
setTimeout(() => {
this.setState({ modalVisible: false});
}, n);
render() {
const { liked } = this.state;
return (
<Modal
animationType="slide"
transparent={true}
onShow={this.closeAfterSomeTime.bind(this, 500)}
visible={this.state.modalVisible}>
<View style={styles.modal}>
{ this.renderLikeStatus(liked) }
</View>
</Modal>
);
}
}
const styles = StyleSheet.create({
modal: {
backgroundColor: 'white',
width: hp('20%'),
height: hp('20%'),
alignSelf: 'center',
borderRadius: 5,
top: hp('40%'),
alignItems: 'center',
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.25,
shadowRadius: 5,
elevation: 10,
},
inputLikeIcon: {
color: '#17bca5',
fontSize: 28,
},
inputDislikeIcon: {
color: '#ff6044',
fontSize: 28,
},
iconContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
iconText: {
color: '#949494',
textAlign: 'center',
fontSize: 20,
}
});
From there, you want to define and initialize the animated values as an array equal to the length of swipe-able items in the list in the constructor of SpotifyFavoriteExample.js.
Notice that I created a data source state that holds an array of 3 placeholder items. These are the items that will be ‘Favorited’.
After that, we will define 3 things. The rendered row, the listener on the rendered row for its ‘swipe’ action and the behavior of the row when it is swiped.
Let’s start with the rendered row. In the <SwipeListView/> component we will define a property called renderItem where we will define how the item for each row will be rendered. From there we will define the threshold of actions for each animation style.
animatedStyle = {
backgroundColor: this.rowSwipeAnimatedValues[item.index].interpolate({
inputRange: [0, 100],
outputRange: ['gray', like_color]
}),
}
iconAnimatedStyle= {
marginRight: this.rowSwipeIconsMove[item.index].interpolate({
inputRange: [0, 100],
outputRange: [wp(6), wp(15)]
}),
position:'relative',
marginBottom: 30
}
frontAnimatedStyle = {
transform: [
{
scale: this.rowSwipeIconsShow[item.index].interpolate({
inputRange: [45, 90],
outputRange: [0, 1],
extrapolate: 'clamp',
}),
}
]
}
backAnimatedStyle = {
transform: [
{
scale: this.rowSwipeIconsHide[item.index].interpolate({
inputRange: [45, 90],
outputRange: [0, 1],
extrapolate: 'clamp',
}),
}
]
}
animatedStyle will change the color of grey to the ‘like_color’ which can be either ‘green’ or ‘red’ based on if the user liked it.
iconAminatedStyle will change transform the right margin of the like icon based off it’s current swipe value.
fontAnimatedStyle will determine if the icon should be a full heart or an empty hart. This style will become a full heart when the user has passed the threshold.
backAnimatedStyle is similar to fontAnimatedStyle. It will change the empty heart icon to a full icon.
Once we have defined how each of our animated style should behave as the input ranged change, we will define the <SwipeRow />’s onSwipeValueChange. This is the listener that will determine what should happen at a certain threshold of a row swipe.
onSwipeValueChange(swipeData, key) {
if(swipeData.value < -140) {
this.rowSwipeAnimatedValues[key].setValue(Math.abs(100));
this.rowSwipeIconsShow[key].setValue(Math.abs(180));
this.rowSwipeIconsHide[key].setValue(Math.abs(0));
} else {
this.rowSwipeAnimatedValues[key].setValue(Math.abs(0));
this.rowSwipeIconsShow[key].setValue(Math.abs(0));
this.rowSwipeIconsHide[key].setValue(Math.abs(100));
}
this.rowSwipeIconsMove[key].setValue(Math.abs(swipeData.value));
}
We hard set all the animated styles to make a set change at an X swipe value of -140. The only animated style that will not listen to the hard set value of -140 is the heart icon’s marginRight value.
From there, we will need to define when a row is ‘favorited’ with the property value, onRowDidOpen.
favoriteItem(rowKey, rowMap) {
const { dataSource } = this.state;
const prevIndex = dataSource.findIndex(item => item.id == rowKey);
let newDataSource;
this.state.favoriteModal.setModalVisible(true, !dataSource[prevIndex].favorite);
dataSource[prevIndex].favorite = !dataSource[prevIndex].favorite;
newDataSource = dataSource;
this.setState({
dataSource: newDataSource
});
rowMap[rowKey].closeRow();
}
We will set this function to trigger when the rowDidOpen function is triggered. This will open the favorite modal to indicate to users that the user has liked the row and also change the row data from unliked to liked or liked to unliked. Then it will close the row.
This is my solution to the Spotify Favorite Feature. If you have a better or more optimized solution to this issue, feel free to hit me up, I’d love to listen to your solution. Other than that, happy coding :).