Timeless
Published in

Timeless

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.

Cover Image by Fayas fs

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:

Dynamic Indicator Animation

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.

Final Dynamic Tab Indicator Interaction

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

UI Interactions With React Native and Reanimated

7 stories
Cover Image for the bloe reading Hamburger Menu Interaction in React Native

This is Karthik from Timeless

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. :)

--

--

An inside look into how we approach design, development and user experience at timeless.co

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store