Implementing Spotify's playlist screen in React Native

Spotify is one of the apps I use the most day-to-day, but I have often overlooked some of the neat UI patterns that they use in their screens. The screen we'll focus on today is the playlist view, and it will look something like this:

Spotify playlist scrollview

At first glance it looks like a straightforward scrollview, but there is a combination of elements that react differently when a user starts scrolling. The middle section, for example, stays in place while the tracks scroll overtop of it, and the shuffle button scrolls with the rest of the page, but comes to rest at the very top of the screen.

Planning out the scrollview

We'll break up these sections into three main parts:

  • the search filters, which appear hidden at first, are visible when a user pulls down
  • the hero section, containing the album cover image, maintains its position as the user scrolls
  • the tracks section, which contains all of the playlist tracks, as well as this cool shuffle button that sort of sits ontop of the tracks section

It can be helpful to visually map out what each element will be doing in our screen:
Sketch of scrollview

Setting up the containers

I like to start by setting up each section with an empty view component. This helps reduce the clutter in our markup and let us focus on just the containers that we'll need to work with. From the looks of it, it's clear we'll need a header component that contains our navigation back button, and then a scrollview component that wraps our three sections.

What we will end up with is something that looks like this:

Initial layout of scrollview

Here is the code for the above:

import React from 'react'
import {SafeAreaView, View, Animated, TouchableOpacity} from 'react-native

function PlaylistProfile() {
return (
<SafeAreaView style=>
<Header />

<Animated.ScrollView style=>
<SearchPlaylists />

<PlaylistHero>
<View style= />
</PlaylistHero>

<PlaylistItems>
<ShufflePlayButton />
</PlaylistItems>
</Animated.ScrollView>
</SafeAreaView>
);
}

// the following components are meant to be containers for content
// ordinarily, the height of a view will be determined by its content (it's intrinsic height)
// we're using preset heights here to get the approximate layout first

// any calculations involving the intrinsic height of a view (in later steps) will be calculated using the onLayout prop -- if this sounds confusing, it will be explained in a later step - dont worry about it for now

const HEADER_HEIGHT = 40;

function Header({children}: any) {
return (
<View style=>
{children}
</View>
);
}

const SEARCH_PLAYLISTS_HEIGHT = 50;

function SearchPlaylists({children}: any) {
return (
<View style=>
{children}
</View>
);
}

const PLAYLIST_HERO_HEIGHT = 300;

function PlaylistHero({children}: any) {
return (
<View
style=>
{children}
</View>
);
}

function PlaylistItems({children}: any) {
return (
<View style=>
{children}
</View>
);
}

const SHUFFLE_PLAY_BUTTON_HEIGHT = 60;
// offset is used to move the button slightly outside its container view
// this gives it the effect of sitting halfway between the hero section and the playlist items section
const SHUFFLE_PLAY_BUTTON_OFFSET = SHUFFLE_PLAY_BUTTON_HEIGHT / 2;

function ShufflePlayButton({children}: any) {
return (
<TouchableOpacity
style=>
{children}
</TouchableOpacity>
);
}

Listening to scroll events

Our screen doesn't do much just yet, so we'll add some logic to listen to scroll events. By adding this listener, we can leverage React Native's Animated API to apply some custom behaviour to our individual containers based on the scroll position of the scrollview.

function PlaylistProfile() {
// this will track the scroll value of the Animated.ScrollView
// use a ref so it doesn't get reset on rerenders
const scrollY = React.useRef(new Animated.Value(0));

// standard boilerplate for listening to scroll events
// useNativeDriver means the scroll value will be updated on the native thread (more efficient)
// this limits what you can do with the Animated.Value - style properties are restricted to transform and opacity
const handleScroll = Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY.current } } }],
{ useNativeDriver: true }
);

// ...
}

Now when a user scrolls, our scrollY value will be updated to whatever the scroll position is, and we can use that position to update our container views.

Updating the Hero section

If we take a look at the diagram above, we know the behaviour of our hero section is that it maintains its position in the window as the user scrolls. We can achieve this by setting the translateY value of this container to be whatever the scroll value is:

function PlaylistProfile() {
// ...
// HERO SECTION TRANSLATIONS
const clampHeroSection = scrollY.current;

return (
<SafeAreaView style=>
<Header />
<Animated.ScrollView onScroll={handleScroll} style=>
<SearchPlaylists />
// Apply the clamped hero translation here:
<TranslationContainer translateY={clampHeroSection}>
<PlaylistHero>
<View
style=
/>
</PlaylistHero>
</TranslationContainer>
<PlaylistItems>
<ShufflePlayButton />
</PlaylistItems>
</Animated.ScrollView>
</SafeAreaView>
);
}

// a wrapper component for translating position with animated values
// doesn't do much, but it cleans up the markup a little bit
function TranslationContainer({ children, translateY }: any) {
return (
<Animated.View style=>
{children}
</Animated.View>
);
}

If you now try scrolling, you'll notice that the hero section looks like it's not moving at all. This is close to what we want, but we do want it to scroll a little bit, for example when the search filters are visible, or when the user pulls down on the scrollview, otherwise it just looks strange.

In other words, we want the hero section to scroll when the position is within a certain range of values. We can achieve this with our scrollY's interpolate function - in this case we'll use this function to translate the hero section until the search filters are offscreen, and at that point freeze the position:

Clamping the hero section

const clampHeroSection = Animated.add(
// we want to make the hero section maintain its position on scroll
// we can do this by setting its translateY value to whatever the scroll value is
scrollY.current,
// shift it up by subtracting points until we've scrolled beyond the search section, and clamp it after that
scrollY.current.interpolate({
inputRange: [0, SEARCH_PLAYLISTS_HEIGHT],
outputRange: [0, -SEARCH_PLAYLISTS_HEIGHT],
// we also want it to shift down when the user pulls down, so we clamp the above range with 'extrapolateRight'
// using just 'extrapolate' would clamp the scroll value in both directions
extrapolateRight: "clamp"
})
);

Updating the Tracks section

The hero section now looks about right. Let's move onto the playlist items - the only concern for this section is managing the position of the shuffle playlist button. It should scroll up until it reaches the top of the screen, and then maintain its position as the user continues scrolling. We can apply similar logic to what we just did with the hero section - when we've scrolled beyond the hero section we'll freeze its position:

function PlaylistProfile() {
// ...
const PLAYLIST_ITEMS_OFFSET = PLAYLIST_HERO_HEIGHT + SEARCH_PLAYLISTS_HEIGHT;

const clampShuffleButton = Animated.add(
// make the button maintain its position during scroll - i.e the center of the window
scrollY.current,
// if we havent scrolled past the hero section, have the shuffle button move up with the scrollview
scrollY.current.interpolate({
inputRange: [0, PLAYLIST_ITEMS_OFFSET - SHUFFLE_PLAY_BUTTON_OFFSET],
outputRange: [0, -PLAYLIST_ITEMS_OFFSET + SHUFFLE_PLAY_BUTTON_OFFSET],
// after reaching the ~300 points translation, maintain the position at the top
extrapolateRight: "clamp"
})
);

return (
<SafeAreaView style=>
<Header />
<Animated.ScrollView onScroll={handleScroll} style=>
<SearchPlaylists />

<TranslationContainer translateY={clampHeroSection}>
<PlaylistHero>
<View
style=
/>
</PlaylistHero>
</TranslationContainer>

<PlaylistItems>
// apply the translation here
<TranslationContainer translateY={clampShuffleButton}>
<ShufflePlayButton />
</TranslationContainer>
</PlaylistItems>
</Animated.ScrollView>
</SafeAreaView>
);
}

Clamping the play button

Setting the initial offset

One thing we haven't addressed yet is that the search filter section is actually hidden when the screen first appears. We can do this by updating the contentOffset prop of the container scrollview. Note that this property only works for iOS platforms, Android will default to initially positioning the scrollview at the top.

  <Animated.ScrollView
contentOffset=
onScroll={handleScroll}>
{...}
</Animated.ScrollView>

Wrapping up

We've essentially finished with our implementation of this screen. All that is left is to add our own content inside of these container sections. You might notice that our reference gif has some more polish to it than we've added - this stuff is fairly straightforward, and a reference can be found on our github example repo, if you'd like to take a look.

Cheers!