Dynamic Tab Indicators in React Native using Reanimated — Part II
Hey everyone! Welcome back!
This blog is a continuation of my previous blog on Building Dynamic Tab Indicators in React Native.
Let us just jump back right into it.
Creating <Tabs /> component
At the end of the previous blog, we would have updated the <FlatListText />
to-you-know, and stored the required values for our Animation in a React state.
We also need to make sure that the state has been updated, and that we have 4 values each before trying to animate.
Using the useAnimatedStyle()
, we can set the Animated Styles for the Indicator Component. The updated <Tabs />
component will look something like this:
const Tabs = ({ scrollXValue }: TabProps) => {
const [viewTranslatePoints, setViewTranslatePoints] = useState<number[]>([]);
const [tabWidths, setTabWidths] = useState<number[]>([]);
const indicatorStyle = useAnimatedStyle(() => {
return tabWidths.length === 4 && viewTranslatePoints.length === 4
? {
width: interpolate(
scrollXValue.value,
[0, SCREEN_WIDTH, 2 * SCREEN_WIDTH, 3 * SCREEN_WIDTH],
[tabWidths[0], tabWidths[1], tabWidths[2], tabWidths[3]],
Extrapolation.CLAMP,
),
transform: [
{
translateX: interpolate(
scrollXValue.value,
[0, SCREEN_WIDTH, 2 * SCREEN_WIDTH, 3 * SCREEN_WIDTH],
[
viewTranslatePoints[0],
viewTranslatePoints[1],
viewTranslatePoints[2],
viewTranslatePoints[3],
],
Extrapolation.CLAMP,
),
},
],
}
: {
width: 0,
transform: [{ translateX: 0 }],
};
});
return (
<Animated.View style={tailwind.style("relative w-full px-4 z-20")}>
<View style={tailwind.style("flex flex-row items-start justify-between")}>
{tabs.map((value, index) => {
return (
<FlatListText
key={value.tabName}
item={value}
index={index}
viewTranslatePoints={viewTranslatePoints}
setViewTranslatePoints={setViewTranslatePoints}
tabWidths={tabWidths}
setTabWidths={setTabWidths}
/>
);
})}
</View>
<Animated.View
style={[
tailwind.style(
"absolute h-2 w-30 bg-white rounded-md -bottom-3 left-4",
),
indicatorStyle,
]}
/>
</Animated.View>
);
};
The code so far would us get an interaction something like this:
Well, that is cool, right?
We have successfully implemented a Dynamic Tab Indicator.
Improving the Indicator Animation
The thing is the snapping is very slow, and there is no springy bouncy motion.
It’s just a matter of some tweaks here and there to make it more fluid.
I think we will not need to use the snapToInterval
prop and do the snap programmatically with the Reanimated spring function.
We will have to add a new Handler to snap when the scrolling ends.
The condition here is very simple. The code will look something like this:
const scrollMomentumEndHandler = useAnimatedScrollHandler({
onMomentumEnd: event => {
const scrollDiff = event.contentOffset.x % SCREEN_WIDTH;
if (scrollDiff > SCREEN_WIDTH / 2) {
const scrollMultiplier = Math.ceil(
event.contentOffset.x / SCREEN_WIDTH,
);
scrollTo(scrollRef, scrollMultiplier * SCREEN_WIDTH, 0, true);
} else {
const scrollMultiplier = Math.floor(
event.contentOffset.x / SCREEN_WIDTH,
);
scrollTo(scrollRef, scrollMultiplier * SCREEN_WIDTH, 0, true);
}
},
});
So this handler is triggered when the user releases his finger from the scroll view and the scrolling ends.
Below is an explanation of the condition. Simple Math!
It calculates the difference between the current position and the nearest multiple of our SCREEN_WIDTH
. If the difference is greater than half of the screen width, it means the user has moved halfway through the screen, so we snap to the next multiple of SCREEN_WIDTH
, if not it will snap back to the previous multiple.
We use the scrollTo()
to animate the scroll view to the new position. It is a function from the Reanimated Library.
It takes in a scrollRef
variable, which is a reference to the Scroll View that should be animated. The second argument is the new X-position as we are translating it horizontally, we set it to multiple of SCREEN_WIDTH
depending upon the scroll difference with respect to the screen’s width. The third argument is the Y-position, which remains 0 because we are just concerned about the ScrollView moving in a Horizontal Direction. The final argument is `animated` which is a boolean set to true
for having an animated scroll snapping.
The updated <AnimatedFlatList />
code will look something like this:
<AnimatedFlatlist
// @ts-ignore
ref={scrollRef}
onMomentumScrollEnd={scrollMomentumEndHandler}
onScroll={scrollHandler}
pagingEnabled
showsHorizontalScrollIndicator={false}
horizontal
scrollEventThrottle={16}
style={tailwind.style("absolute z-0")}
data={tabs}
renderItem={({ item }) => {
return <FlatListImage item={item as TabsProps} />;
}}
/>
Well, let us now see how this works.
This works great, isn’t it?
Well well well!
We have created dynamic Tab Indicators in React Native with Reanimated Library.
The final code will look something like this:
import { useState } from "react";
import {
Dimensions,
FlatList,
Image,
ImageSourcePropType,
LayoutChangeEvent,
StatusBar,
Text,
View,
} from "react-native";
import Animated, {
Extrapolation,
interpolate,
scrollTo,
SharedValue,
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { LinearGradient } from "expo-linear-gradient";
import tailwind from "twrnc";
const SCREEN_HEIGHT = Dimensions.get("screen").height;
const SCREEN_WIDTH = Dimensions.get("screen").width;
type TabsProps = {
tabName: string;
imageSrc: ImageSourcePropType;
};
const tabs: TabsProps[] = [
{ tabName: "Men", imageSrc: require("../assets/men.jpg") },
{ tabName: "Women", imageSrc: require("../assets/women.jpg") },
{ tabName: "Kids", imageSrc: require("../assets/kid.jpg") },
{ tabName: "Home Decor", imageSrc: require("../assets/home-decor.jpg") },
];
const AnimatedFlatlist = Animated.createAnimatedComponent(FlatList);
type FlatListImageProps = { item: TabsProps };
type FlatListTextProps = {
item: TabsProps;
index: number;
viewTranslatePoints: number[];
setViewTranslatePoints: React.Dispatch<React.SetStateAction<number[]>>;
tabWidths: number[];
setTabWidths: React.Dispatch<React.SetStateAction<number[]>>;
};
const FlatListImage = ({ item }: FlatListImageProps) => {
return (
<Animated.View
key={item.tabName}
style={tailwind.style(`h-[${SCREEN_HEIGHT}px] w-[${SCREEN_WIDTH}px]`)}
>
<Image style={tailwind.style("h-full w-full")} source={item.imageSrc} />
</Animated.View>
);
};
const FlatListText = ({
item,
index,
viewTranslatePoints,
setViewTranslatePoints,
tabWidths,
setTabWidths,
}: FlatListTextProps) => {
const handleViewLayout = (event: LayoutChangeEvent) => {
const { x } = event.nativeEvent.layout;
const currentPoints = [...viewTranslatePoints];
currentPoints[index] = x;
setViewTranslatePoints(currentPoints);
};
const handleTextLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
const currentTabWidths = [...tabWidths];
currentTabWidths[index] = width;
setTabWidths(currentTabWidths);
};
return (
<Animated.View onLayout={handleViewLayout} key={item.tabName}>
<Text
onLayout={handleTextLayout}
style={tailwind.style("text-lg font-bold text-white")}
>
{item.tabName}
</Text>
</Animated.View>
);
};
type TabProps = {
scrollXValue: SharedValue<number>;
};
const Tabs = ({ scrollXValue }: TabProps) => {
const [viewTranslatePoints, setViewTranslatePoints] = useState<number[]>([]);
const [tabWidths, setTabWidths] = useState<number[]>([]);
const indicatorStyle = useAnimatedStyle(() => {
return tabWidths.length === 4 && viewTranslatePoints.length === 4
? {
width: interpolate(
scrollXValue.value,
[0, SCREEN_WIDTH, 2 * SCREEN_WIDTH, 3 * SCREEN_WIDTH],
[tabWidths[0], tabWidths[1], tabWidths[2], tabWidths[3]],
Extrapolation.CLAMP,
),
transform: [
{
translateX: interpolate(
scrollXValue.value,
[0, SCREEN_WIDTH, 2 * SCREEN_WIDTH, 3 * SCREEN_WIDTH],
[
viewTranslatePoints[0],
viewTranslatePoints[1],
viewTranslatePoints[2],
viewTranslatePoints[3],
],
Extrapolation.CLAMP,
),
},
],
}
: {
width: 0,
transform: [{ translateX: 0 }],
};
});
return (
<Animated.View style={tailwind.style("relative w-full px-4 z-20")}>
<View style={tailwind.style("flex flex-row items-start justify-between")}>
{tabs.map((value, index) => {
return (
<FlatListText
key={value.tabName}
item={value}
index={index}
viewTranslatePoints={viewTranslatePoints}
setViewTranslatePoints={setViewTranslatePoints}
tabWidths={tabWidths}
setTabWidths={setTabWidths}
/>
);
})}
</View>
<Animated.View
style={[
tailwind.style(
"absolute h-2 w-30 bg-white rounded-md left-4 -bottom-3",
),
indicatorStyle,
]}
/>
</Animated.View>
);
};
export const DynamicTabBar = () => {
const { top } = useSafeAreaInsets();
const scrollValue = useSharedValue(0);
const scrollRef = useAnimatedRef();
const scrollHandler = useAnimatedScrollHandler({
onScroll: event => {
scrollValue.value = event.contentOffset.x;
},
});
const scrollMomentumEndHandler = useAnimatedScrollHandler({
onMomentumEnd: event => {
const scrollDiff = event.contentOffset.x % SCREEN_WIDTH;
if (scrollDiff > SCREEN_WIDTH / 2) {
const scrollMultiplier = Math.ceil(
event.contentOffset.x / SCREEN_WIDTH,
);
scrollTo(scrollRef, scrollMultiplier * SCREEN_WIDTH, 0, true);
} else {
const scrollMultiplier = Math.floor(
event.contentOffset.x / SCREEN_WIDTH,
);
scrollTo(scrollRef, scrollMultiplier * SCREEN_WIDTH, 0, true);
}
},
});
return (
<View style={tailwind.style(`flex-1 pt-[${top}px] bg-[#141414]`)}>
<StatusBar barStyle={"light-content"} />
<Tabs scrollXValue={scrollValue} />
<LinearGradient
colors={["rgba(0,0,0,1)", "transparent"]}
style={tailwind.style(`absolute inset-0 h-[${top * 2}px] z-10`)}
/>
<AnimatedFlatlist
// @ts-ignore
ref={scrollRef}
onMomentumScrollEnd={scrollMomentumEndHandler}
onScroll={scrollHandler}
pagingEnabled
showsHorizontalScrollIndicator={false}
horizontal
scrollEventThrottle={16}
style={tailwind.style("absolute z-0")}
data={tabs}
renderItem={({ item }) => {
return <FlatListImage item={item as TabsProps} />;
}}
/>
</View>
);
};
See you all in the next blog!
Thank you for reading, and I hope you enjoyed this blog.
This was part of the Weekly Tweets of “Timeless take on UI Interaction”.
Follow Timeless and stay tuned for the next Interaction.
You can check out all my UI Interaction related blogs



I hope you found this blog post helpful and informative. If you have any feedback or suggestions, please leave a comment below. I’d love to hear your thoughts and opinions on the topic.
And if you have any ideas for future blog posts, let me know! I’m always looking for new and interesting topics to explore.
Thank you for reading, and I look forward to hearing from you. :)