React Native SoundCloud Replica

Animation + Navigation

Photo by Rachit Tank on Unsplash

Everyday I code, there’s one app that always accompanies me. My partner in crime. My paramour. Lately, I find myself analyzing every detail of every app I come across. More than anything else, navigation and animation have piqued my interest. So it should be no great surprise that my gaze has fallen upon my nearest and dearest ally of the trenches — SoundCloud.

I thoroughly enjoy SoundCloud. One specific feature I love is the image scroll on mobile. I find this provides the artist an additional prop for the story being told. This is such an asset, and when done well, you can tell.

Bright-eyed and bushy-tailed I set to task. I decided I would make a SoundCloud replica and extract some data from their API.

‘Oh my sweet summer child.’

It seems some months ago SoundCloud stopped giving away API keys. At first I was sure I’d just ‘borrow’ one.

Frustratingly, I realized this wouldn’t help much either for authentication reasons. UNDETERRED, I pushed onward. I reminded myself what set me on this path in the first place.

Animation + Navigation

Let’s get to work!

First let’s set up our environment. If you’re using a Mac make sure you have Homebrew, Yarn, and either Xcode or Android Studio installed. If you’re on Windows, make sure you have Yarn installed and Android Studio setup. I went over how to do this in my last article.

Let’s get react-native installed.

npm install -g react-native-cli

Now to create our project. Let’s call it SoundCloud.

react-native init SoundCloud

The next few commands will be to cd into our project, install some dependencies, and start up either the iOS simulator or Android emulator.

cd SoundCloud

yarn add react-navigation

yarn add react-native-vector-icons

react-native link

I’ll be using the iPhone X as my simulator:

react-native run-ios --simulator='iPhone X'

For Android:

react-native run-android

NAVIGATION

Open up your editor of choice. Before we make any adjustments to App.js, let’s create a new folder named ‘screens.’ Inside of screens create two files:

(1) Login.js

(2) Home.js

Inside of Login.js insert the following code:

For Home.js:

Now adjust App.js such that it resembles the below code:

In both Login.js and Home.js we simply import a few react-native components, create the LoginScreen component, the HomeScreen component, and insert a button. We give the button a title and we implement the react-navigation syntax for ‘switching’ to another screen.

Back in App.js we import our components and react-navigation. We then create a SwitchNavigator in which we name Switch. Inside of Switch we assign the name ‘Login’ to our LoginScreen component in Login.js, and do the same for our HomeScreen component in Home.js. Since we named Login first, it’ll become the ‘initial route’ for our app. A better way to do this more declaratively is to add ‘initialRouteName’ to our SwitchNavigator.

Once you save your project you’ll be able to navigate between the LoginScreen and the HomeScreen. Cool! Now let’s finish Login.js so that we don’t have to bother with it again.

First we import react-native-vector-icons. In our render method we insert our icon above our button. We style both the icon and the button, make them both responsive, and change our background color.

Sweet! We’ve mastered the SwitchNavigator! Onward!

Back in the screens folder create three more files:

(1) Stream.js

(2) Search.js

(3) Profile.js

We’ll deal with Stream.js first:

Now Search.js:

Lastly Profile.js:

I also went into Home.js and adjusted the Button title to say ‘I’m the HomeScreen’, and the onPress method to navigate to the StreamScreen which we’ll soon name ‘Stream.’ Now that we have our screens, we’ll need to import them into App.js. We’re also going to be importing a few dependencies. Inside App.js adjust your imports to the following:

Here we import createBottomTabNavigator from react-navigation. We also import icons from react-native-vector-icons MaterialIcons and MaterialCommunityIcons directory. Lastly, we import the rest of our screens.

Now that we’ve imported our tabNavigator, we might as well use it. Our goal is to go from the LoginScreen to the HomeScreen, which will be the initialRoute of our tabNavigator. This requires the following adjustments to App.js:

First, we create Tabs. Inside we set up our routeName’s to their components. Next, we use react-navigation’s navigationOptions. Inside of navigationOptions we use destructuring and conditional statements to assign our icons to their appropriate screens. After we assign each screen their icon, we use tabBarOptions to style them. Lastly, we adjust our SwitchNavigator’s second screen to Tabs. This way we go from logging in, directly to our tabNavigator.

To get rid of this pesky warning adjust index.js like so:

BEHOLD! We’ve concurred the TabNavigator! Our next specimen — StackNavigator. We’ll want to use a StackNavigator in order to navigate to new screens within each tab. But first, we should make at least one more screen in which we’ll navigate to. Let’s get to it!

In App.js we need to import createStackNavigator from ‘react-navigation.’

import { createSwitchNavigator, createBottomTabNavigator, createStackNavigator } from 'react-navigation'

We also need to import the ‘Button’ component from ‘react-native.’

Once you’ve made the necessary adjustments, just below our imports and above our TabNavigator, add the following code:

First we create our SongScreen component. Next, we add a Button like usual and style it. Finally, we create four StackNavigators and assign each their individual screens. For this to work, it must communicate with the TabNavigator some how. We can achieve this by adjusting the routes of our TabNavigator to be the names of our StackNavigators.

Go into Home.js and adjust the Button’s onPress navigation route to be ‘Song’ and the title to ‘Play Song.’ Then at the top of the HomeScreen component, above the render method, add the following code:

Once you save your project you’ll see the following:

As you can see, StackNavigator provides us with cool methods to style our headers and a back arrow to navigate back and forth between screens. We now have our HomeStack navigator working nicely. The process for the StreamStack, SearchStack, and the ProfileStack is the same.

We’re going to want to head into Stream.js, Search.js, and Profile.js in order to create the same static method we did in Home.js. You can adjust the Button onPress method for each screen such that each stack proceeds to the SongScreen or any additional screens you’d like.

ANIMATION

In App.js we’re going to need to adjust our imports one last time. We’ll need some more components from react-native and some more icons from the react-native-vector-icons Feather and Ionicons directory. Make the adjustments shown below:

Create a new folder named ‘images.’ The image we place here will be the SongScreen image that scrolls horizontally, mimicking the SoundCloud functionality. If you’d like to use the same image that I am, you can find it here. Download this image or one of your own choosing, and drag it into the images folder.

In App.js, just below our imports and above the SongScreen component, add these two global variables:

const SCREEN_WIDTH = Dimensions.get("window").width const SCREEN_HEIGHT = Dimensions.get('window').height

These two quite self-explanatory variables get the height and width of whichever device we happen to be using.

When listening to a song on SoundCloud’s mobile app, the image takes up the entire height of the screen. There’s no header and no tabBar. To rid ourselves of the header is easy as implementing a static method like the one we used in our other screens. At the very top of the SongScreen component insert the following code:

static navigationOptions = { header: null }

With the very recent release of react-navigation 2.0.1 there have been some significant changes. In the older versions we could have simply used tabBarVisible: false in the static method above to rid ourselves of the tabBar. It’s not as easy now but we’re up to the task! Just below your StackNavigators insert the following code:

Once you save your project you’ll notice the header and tabBar are gone. Lets go over this code to cement our understanding of what’s going on here. We use destructuring to place navigation.state.routes[navigation.state.index] in the variable routeName. This places our current route (or screen) in the routeName variable. We then implement a conditional statement that says, ‘If this is the SongScreen get rid of that pesky tabBar.’ This is all connected to our HomeStack StackNavigator. We could do the same for the other three stacks as-well, but I’ll leave that to you.

Inside of our SongScreen render function we’re going to wrap our entire component inside of a SafeAreaView component we imported courtesy of react-native. This component simply ensures that our app looks presentable across all devices (particularly made for dealing with the iPhone X’s larger screen). From everything I’ve read it’s just best practice to do this.

Inside of this SafeAreaView component we’ll keep our current View component, which contains our Button. Under this View we’re going to implement an Animated.View. This comes to us courtesy of the Animated API we imported via react-native. It’s the same as a normal View except we can use animations inside of it. Inside of this Animated.View we’ll implement an Animated.Image component which allows us to animate our Image.

In order for our image to scroll it must be larger than the width of the screen. This is why the width is set to SCREEN_WIDTH * 3. This makes our image three screens wide. So when we animate our scroll, it will have to scroll the length of two screens. Don’t worry about the commented out marginLeft yet, we’ll come back to that. Also don’t worry about the styling of the Animated.View labeled ‘imageContainer’ yet. At this point it’s empty.

You’ll notice the image we’ve imported that I named goodbye.jpg is laying on top of our View containing the Button that is currently our only means of navigating away from the SongScreen. We’ll deal with this later but for now you can just reload the simulator(command + R on Mac) to return to the Login screen. Now to handle our x-axis scroll animation.

After the static method we utilized to rid ourselves of the header insert the below code:

Inside of our constructor function we create a new Animated.Value. Afterwards, we use componentDidMount to initialize a function we name animate. Inside of this animate function we set animatedValue to 0. Think of this as initializing state and later using setState to adjust the value.

The Animated API has multiple ‘types’ and Animated.timing is one of them. Let’s take a look at the docs.

We set toValue to 1. This will take our initial value of 0 to 1. You can think of 0 as 0% and 1 as 100%. We then use easing: Easing.linear to scroll at a steady rate.

We set our scroll duration to 90000ms or 90 seconds. This would normally be set to the duration of whatever song was being played but we don’t have the luxury of the SoundCloud API so 90 seconds will do. We then use .start() to start the animation. If we left it at that the animation would begin and stop once the minute and a half was up. To make the animation loop we use the code below to repeat the process.

.start(() => this.animate())

One last thing is needed for our scroll to work. Under the render function but above the return statement, add the code below:

Here we create that curious marginLeft variable that was commented out of our Animated.Image component inside our return statement. Interpolation maps input ranges to output ranges. So if 0 = 0% and 1 = 100% then we want to start our left margin at the beginning(or 0%) and end once the image scrolls two more screen widths(100%) because the image width is SCREEN_WIDTH * 3.

Uncomment marginLeft in the Animated.Image and reload the simulator or emulator and you should see the following animation. I’ll have to speed it up a bit in order to convert it into a gif but you get the idea.

I’m truly terrible at making gifs. I do this picture zero justice! 😡 Anyways, ONWARD!

This is a SoundCloud replica so let’s make our SongScreen Image look the part. Under the Animated.Image but inside the outermost Animated.View and SafeAreaView, insert the following code:

Here we implement some icons and text. We separate them into three categories: Uppermost Icons, UpperIcons, and LowerIcons. This will be more clear once we style them and take a look at the simulator so let’s do that. Update StyleSheet accordingly:

Save App.js and take a look at the simulator.

Now we know what uppermost, upper, and lower icons are. But why have we left the uppermost icons(and text) so high in our View? If you’re familiar with SoundCloud on mobile then you know that on y-axis scroll(or pan), you can animate the SongScreen Image to the bottom of the View. The only part of the View visible when this is done is the uppermost icons. This also exposes the View behind the Animated.Image, giving us access to our navigation button again.

That said, the uppermost icons don’t just stay there the entire time. They fade in once the Animated.Image has reached the bottom of the View. Likewise, the Animated.Image and it’s associated icons fade from View. We’ll have to make our Image scroll down on pan and create some opacity animation.

Below our static navigationOptions method and above our constructor function, add the following code:

First we utilize react-native’s componentWillMount life-cycle method. Inside, we initialize ‘animation’ and set it’s x and y value’s to 0. Next we create a react-native PanResponder that we imported earlier. Let’s take a look at the docs.

Let’s ask ourselves what functionality we’re looking for. Going through this code step by step will lend some clarity.

onMoveShouldSetPanResponder: ()=> true,

Here we ask permission to be the responder so that whenever we gesture(or pan) the screen, the PanResponder becomes responsive.

onPanResponderMove: (evt, gestureState) => { this.animation.setValue({x: 0, y: gestureState.dy}) }

onPanResponderMove tracks the movement of the gesture across the screen. We’re not worried about the x-axis so we leave it at 0. We use the gestureState parameter to track the location of the y-axis and set its value to wherever the users finger is on the screen.

onPanResponderRelease is called when the user removes their finger from the screen. Inside of this method we have three conditional statements. Let’s start with the second one.

else if(gestureState.dy < 0) { Animated.spring(this.animation.y, { toValue: 0, tension: 1 }).start() }

The first thing to understand is when we pan up the screen, that this is interpreted as a negative number. Conversely when we pan down the screen this is interpreted as a positive number. So gestureState.dy < 0 is saying, ‘If we pan up the screen do the following.’

So when we scroll up we implement Animated.spring, pass it the animation state, and tell the image to go back to the top of the screen. Since we’re using Animated.spring to ‘spring’ the image into place, we utilize tension: 1 which will stop the image from bouncing into place. Finally, we start the animation.

else if(gestureState.dy > 0) { Animated.spring(this.animation.y, { toValue: SCREEN_HEIGHT - 60, tension: 1 }).start() }

Here we do the exact same thing except for panning down the screen. Remember positive numbers go down and negative number go up. So if we’re passing the SCREEN_HEIGHT as a number, it’ll start us at the bottom of the screen. We then subtract 60 in order to place the image 60 units above the bottom of the screen.

We’ll come back to the first conditional statement later once we have a better understanding of why it’s needed.

In our render method and above the return statement add the code below:

Inside animatedHeight we store the method that interpolates our gestures on the screen into positive and negative numbers. That method is getTranslateTransform().

animatedIconOpacity changes our upperMostIcons opacity on scroll. animatedScreenOpacity does the same for our Animated.Image’s text and icons. We use ‘clamp’ to make sure the opacity values stay in the desired range.

The input ranges on animatedScreenOpacity and animatedIconOpacity are almost identical — only reversed. The idea here is to have the upperMostIcons fade in around the same time as animatedScreenOpacity fades out. animatedIconOpacity starts at the top of the screen, proceeds to the bottom-200, and ends at the bottom-60. We’ll see how this looks in the simulator in a moment.

In our return statement we’re going to need to pass in our PanResponder and add our animatedHeight and animatedOpacity values. We’ll deal with our outermost Animated.View first. Make the following adjustments:

<Animated.View {...this.PanResponder.panHandlers} style={[ animatedHeight, styles.imageContainer]}>

Next adjust the upperMostIcons Animated.View like so:

<Animated.View style={[ styles.upperMostIcons, {opacity: animatedIconOpacity }]}>

Now for our upperIcons:

<Animated.View style={[ styles.upperIcons, { opacity: animatedScreenOpacity }]}>

Lastly, the lowerIcons:

<Animated.View style={[ styles.lowerIcons, { opacity: animatedScreenOpacity }]}>

Once you save App.js you should see the following animation:

You’ll notice how the image ‘springs’ to the bottom and top but doesn’t bounce. You’ll also notice that we pan down the screen to get the image to the bottom. But, onPress the image springs to the top of the screen, mimicking the functionality of SoundCloud.

However, if we wished to roguishly alter this behavior(which I will not) we would add the following code inside of our PanResponder:

At the end of the above gif, I demonstrated an edge case. Allowing the user to pan the image off the screen would be a bit silly. What did we do to stop this from happening? Just what sorcery is at work here? Remember that first conditional statement inside of onPanResponderRelease? Let’s take another look:

if(gestureState.moveY < SCREEN_HEIGHT && gestureState.dy < 0) { Animated.spring(this.animation.y, { toValue: 0, tension: 1 }).start() }

if(y-axis gesture is anywhere on the screen && scrolling up){ Send image back to initial value and don’t let it bounce} I think this is easier to understand after seeing the demonstration in the gif(at least I hope that it is).

One last thing to do. Whatever happened to our button in the first View that navigated us home? If you get rid of flex: 1 in styles.container, and provide a marginTop of say 55, you’ll see it. That said, I’ve grown tired of this button. It has outstayed its welcome, and I would much rather have it replaced. Adjust the View just inside the SafeAreaView and above our first Animated.View accordingly:

You’ll notice we added a View just under our old one. Creating this View and passing it a flex: 1, simply tells react to fill the screen with this View except for the parts we care about — the View above it. Now adjust StyleSheet’s container and add the following two items to StyleSheet:

Once you save App.js our project will be complete. I made a minor adjustment in the video below. I changed the SoundCloud Icon’s size in Login.js from a ridiculous 50 to a more reasonable 100 units. I also tried my best to replicate what I love about SoundCloud by adding a piece of one of my favorite coding songs that just happens to feature my favorite human of all time — Carl Sagan.

Let’s see how it looks.

HUZZAH!! It was a battle, but hopefully we left no one behind while conquering some of SoundCloud’s functionality!

Until next time!