Redesigning Gallery Carousel with Shared Element Transition

Karthik Balasubramanian
Timeless
7 min readSep 19, 2023

--

Hey everyone.

Cover Image

This is an extended version of the Gallery Carousel, where I would have updated the same component with a new design to give a more polished look.

The redesign would involve just adding beautiful music album covers and Album names with no. of tracks in the Album.

The interaction would also make the text appear/disappear.

Title/Subtitile Appear/Disappear

Redesign of Gallery Carousel

So when I showed this interaction to Sandeep Prabhakaran, and he had some pointers/changes to make the interaction visually appealing. So we…

1. Changed the static images into beautiful Music Album covers from Apple.

2. Added album title and the no. of tracks as sub-text, animated them based on the scroll offset.
For that, we added a section.

<Animated.View style={[tailwind.style("pt-4"), titleAnimationStyle]}>
<Animated.Text style={[tailwind.style("text-[22px] font-semibold text-[#171717]")]}>{item.title}</Animated.Text>
<Animated.Text style={tailwind.style("text-[15px] font-normal text-[#979797]")}>{item.subtitle}</Animated.Text>
</Animated.View>

And add the animated style using the useAnimatedStyle() from Reanimated, interpolate the opacity with the scrollXOffset.

const titleAnimationStyle = useAnimatedStyle(() => {
return {
opacity: interpolate(scrollXOffset.value, inputRange, [0, 1, 0]),
};
});

3. Removed the manual navigation arrows.

4. Added artist's row, just so that the screen isn't all blank, while we record the video.

And with some style changes, I got my code to match the design.

File from Figma (Left Side), Screenshot from Mobile (Right Side)

Well, this wraps the redesign.

Now moving out to the Shared Element Transition.

Shared Element Transition with Reanimated v3

To have that, we will require a new screen, let's call it the “AlbumDetails” and add it to a <Stack.Navigator />

<NavigationContainer>
<SafeAreaProvider>
<Stack.Navigator screenOptions={{headerShown: false}}>
<Stack.Screen name="Music Album" component="{PlaylistHome}" />
<Stack.Screen
name="Album Details"
options={{presentation: "card"}}
component="{AlbumDetails}"
/>
</Stack.Navigator>
</SafeAreaProvider>
</NavigationContainer>

With Reanimated, introducing the Experimental Shared Element Transition, it has been simple to integrate it in our app.

So the requirement is to have the two components on different screens, which we have. Assign a sharedTransitionTag to both components.

So while transitioning, all properties are animated with a duration of 500ms, withTiming().

See more in docs.

We move on now and create the Album Details screen. We will have a scaled up cover photo, with songs listed.

The code for the same is below.

import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { BlurView } from "expo-blur";
import { Image, Pressable, View } from "react-native";
import Animated, { Extrapolate, FadeInDown, interpolate, useAnimatedProps, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import tailwind from "twrnc";
import { UIInteractionParamList } from "../../../App";
import { ChevronLeft } from "../../Icons/ChevronLeft";
import { ThreeDot } from "../../Icons/ThreeDot";
import { DETAILS_CARD_WIDTH, carouselItems, shadows, songs } from "../constants";

const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);

export const AlbumDetails = ({ navigation, route }: NativeStackScreenProps<UIInteractionParamList, "Album Details">) => {
const { id } = route.params;
const { top } = useSafeAreaInsets();
const scrollY = useSharedValue(0);

const item = carouselItems.find((item) => item.id === id);

const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = withSpring(event.contentOffset.y);
},
});

const headerTitleAnimationStyle = useAnimatedStyle(() => {
return {
opacity: interpolate(scrollY.value, [383, 383 + 1], [0, 1], Extrapolate.CLAMP),
};
});

const animatedBlurViewProps = useAnimatedProps(() => {
return {
intensity: interpolate(scrollY.value, [0, 10, DETAILS_CARD_WIDTH + 65], [0, 80, 100], Extrapolate.CLAMP),
};
});

return (
<View style={tailwind.style("flex-1 bg-white", `pt-[${top}px]`)}>
<Animated.View style={tailwind.style("absolute inset-0")}>
<Animated.Image source={item?.image} style={[tailwind.style("h-full w-full bg-white")]} />
<AnimatedBlurView style={tailwind.style("absolute inset-0")} intensity={100} />
<Animated.View style={tailwind.style("absolute inset-0 bg-white opacity-70")} />
</Animated.View>

<Animated.ScrollView scrollEventThrottle={16} onScroll={scrollHandler} showsVerticalScrollIndicator={false} contentContainerStyle={[tailwind.style(`overflow-visible mt-[${60}px] pb-25`)]}>
<Animated.View style={[tailwind.style("flex items-center")]}>
<Animated.View sharedTransitionTag={`${item?.id}-image-wrapper`} style={[tailwind.style(`w-[${DETAILS_CARD_WIDTH}px] h-[${DETAILS_CARD_WIDTH}px]`, "bg-white"), shadows[4]]}>
<Animated.Image sharedTransitionTag={`${item?.id}-${item?.title}-image`} source={item?.image} style={[tailwind.style("h-full w-full bg-white")]} />
</Animated.View>
</Animated.View>
<Animated.View entering={FadeInDown.delay(300).springify().damping(18).stiffness(120)} style={[tailwind.style("pt-5 items-center")]}>
<Animated.Text style={[tailwind.style("text-[22px] font-semibold text-[#171717]"), { lineHeight: 25.3 }]}>{item?.title}</Animated.Text>
<Animated.Text style={[tailwind.style("text-[15px] font-normal text-[#979797] pt-2"), { lineHeight: 17.25 }]}>Updated last week</Animated.Text>
</Animated.View>
<Animated.View entering={FadeInDown.delay(300).springify().damping(18).stiffness(120)} style={tailwind.style("mt-9 flex items-start pl-5 mr-5")}>
{songs.map((song) => {
return (
<View style={tailwind.style("h-[58px] flex flex-row items-center mt-2")} key={song.id}>
<Image style={tailwind.style("h-12 w-12 rounded-[5px]")} source={song.image} />
<Animated.View style={tailwind.style("ml-3 border-b-[1px] flex-1 flex-row items-center justify-between py-2.5 border-[#0000000D]")}>
<Animated.View>
<Animated.Text style={[tailwind.style("text-base"), { lineHeight: 18.4 }]}>{song.title}</Animated.Text>
<Animated.Text style={[tailwind.style("text-[14px] text-[#00000073] pt-1"), { lineHeight: 16.1 }]}>{song.subtitle}</Animated.Text>
</Animated.View>
<Animated.View>
<ThreeDot />
</Animated.View>
</Animated.View>
</View>
);
})}
</Animated.View>
</Animated.ScrollView>
<AnimatedBlurView animatedProps={animatedBlurViewProps} style={tailwind.style(`absolute top-0 left-0 right-0 h-[${top + 50}px] pt-[${top}px] z-10`)}>
<Pressable style={tailwind.style("flex flex-row items-center mt-2.5")} onPress={() => navigation.pop()}>
<Animated.View style={tailwind.style("absolute pl-2")}>
<ChevronLeft />
</Animated.View>
<Animated.View style={[headerTitleAnimationStyle, tailwind.style("w-full")]}>
<Animated.Text style={tailwind.style("text-[17px] text-center font-semibold text-[#171717]")}>{item?.title}</Animated.Text>
</Animated.View>
</Pressable>
</AnimatedBlurView>
</View>
);
};

I would have taken the album cover image, stretched it, placed it absolutely behind the list component to give a blurry background of the cover.

We end up with these beautiful Playlist/Album details screen.

If you see the code, we would have added the sharedTransitionTag to the wrapper and the image.

<Animated.View 
sharedTransitionTag={`${item?.id}-image-wrapper`}
style={[
tailwind.style(
`w-[${DETAILS_CARD_WIDTH}px] h-[${DETAILS_CARD_WIDTH}px]`,
"bg-white"
),
shadows[4]
]}
>
<Animated.Image
sharedTransitionTag={`${item?.id}-${item?.title}-image`}
source={item?.image}
style={[tailwind.style("h-full w-full bg-white")]}
/>
</Animated.View>

We have to make sure it is unique and is also added to the Images in the Carousel.

<Animated.View 
sharedTransitionTag={`${item?.id}-image-wrapper`}
style={[
tailwind.style(
`w-[${CARD_WIDTH}px] h-[${CARD_WIDTH}px]`,
"bg-white"
),
shadows[4]
]}
>
<Animated.Image
sharedTransitionTag={`${item?.id}-${item?.title}-image`}
source={item.image}
style={[tailwind.style("h-full w-full bg-white")]}
/>
</Animated.View>

I would have set different sizes for the images on both screens to get a scale animated shared element transition.

So we end up with a nice shared element transition.

Shared Element Transition with Reanimated 3

We make the second screen content appear using the default Reanimated’s Layout Animations.

entering={FadeInDown.delay(300).springify().damping(18).stiffness(120)}

Adding this prop to our wrapper will give you the desired transition/animation, in here it is the Fade from down. The delay is set to 300ms because the Shared Element Transition takes 500ms to complete, so when it is half way there, we animate content to appear.

Scroll & Header Appear in List screen

While there is scrolling happening in the List Screen, we can make a blur background header to appear, like how it happens in Apple Music.

So we add the Blur Container after our screen content, so that is placed on the top layer and blurs the content below them, place it absolutely.

<AnimatedBlurView 
animatedProps={animatedBlurViewProps}
style={
tailwind.style(
`absolute top-0 left-0 right-0 h-[${top + 50}px] pt-[${top}px] z-10`
)}
>
<Pressable style={tailwind.style("flex flex-row items-center mt-2.5")} onPress={() => navigation.pop()}>
<Animated.View style={tailwind.style("absolute pl-2")}>
<ChevronLeft />
</Animated.View>
<Animated.View style={[headerTitleAnimationStyle, tailwind.style("w-full")]}>
<Animated.Text style={tailwind.style("text-[17px] text-center font-semibold text-[#171717]")}>{item?.title}</Animated.Text>
</Animated.View>
</Pressable>
</AnimatedBlurView>

The height of the container is the sum of the top inset (using useSafeAreaInsets()) and the desired height of the header (here it is 50px).

As the whole thing works based on the scrolling, we will have to add a scrollHandler variable, using useAnimatedScrollHandler() to our ScrollView to keep track of the scrollY, which is a SharedValue variable.

  const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = withSpring(event.contentOffset.y);
},
});

Using this scrollY, we can change the intensity of the blur in the header and also make the header title appear.

Let us check first for the blur intensity.

const animatedBlurViewProps = useAnimatedProps(() => {
return {
intensity: interpolate(
scrollY.value,
[0, 10, DETAILS_CARD_WIDTH + 65],
[0, 80, 100],
Extrapolate.CLAMP
),
};
});

We interpolate the scrollY value, and set the intensity to 100 after the view has scrolled beyond a value.

Now, let us look at the headerTitleAnimation, for this we need to make the header title to appear when the scroll has passed over the Title which is inside our screen content.

const headerTitleAnimationStyle = useAnimatedStyle(() => {
return {
opacity: interpolate(
scrollY.value,
[383, 383 + 1],
[0, 1],
Extrapolate.CLAMP
),
};
});

So it is similar to how we increased the blur of the background, we set the opacity to 1 when the blur background has scrolled behind the header.

We calculate the value 383 (Top inset + Header + Image height + Title height).

You can move this as a constant.

With this, you get a similar interaction which is in Apple Music.

Apple Music (Left) Our Interaction with React Native & Reanimated (Right)

If you liked this, show some love here. 👇🏼

Tweet part of the UI Interactions with React Native and Reanimated

In the next blog, let us see how to add Pagination to the Carousel, an optional feature which might be useful.

A request from one of my readers. 🤌🏻😇

I am open to suggestions, please do drop anything related to Interactions, you would like to know how. I will be happy to write something about it.

Take care, see ya in my next blog!

This is Karthik from Timeless

Recently, I reworked by Bento Profile. 🙈

Please check it out as well! 🙃🙃🙃

--

--