Redesigning Gallery Carousel with Shared Element Transition
Hey everyone.
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.
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.
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.
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.
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.
If you liked this, show some love here. 👇🏼
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!
Recently, I reworked by Bento Profile. 🙈
Please check it out as well! 🙃🙃🙃