I’m building a swipeable tab system in my React Native app using react-native-reanimated and react-native-gesture-handler. The user can swipe between tabs or click on the tab titles to navigate. Swiping between tabs works perfectly, but clicking on a tab title only works once. After clicking a title, I must swipe again before being able to click another title.
Below is the current implementation:
Code for Home.tsx (Main Component):
import React, { useState, useEffect } from "react";
import { Dimensions, View } from "react-native";
import {
PanGestureHandler,
PanGestureHandlerGestureEvent,
} from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from "react-native-reanimated";
import { StatusBar } from "expo-status-bar";
import { SafeAreaView } from "react-native-safe-area-context";
import { FeedProvider, useAuth } from "replyke-rn";
import { Feed } from "../../components/home/Feed";
import { cn } from "../../utils/cn";
import { TabsSlidingTitles } from "../../components/home/TabsSlidingTitles";
import { FEEDS, feedTitles } from "../../components/home/feeds";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
type ScreenCount = 1 | 2 | 3 | 4;
const containerWidth: Record<ScreenCount, string> = {
1: "w-full",
2: "w-[200%]",
3: "w-[300%]",
4: "w-[400%]",
};
const Home = () => {
const { user } = useAuth();
// Keep your original React state for the active feed:
const [currentIndex, setCurrentIndex] = useState<number>(1); // start on the second feed
// Keep a shared value in sync with that state:
const currentIndexSV = useSharedValue(currentIndex);
// On each render if currentIndex changes, update currentIndexSV
useEffect(() => {
currentIndexSV.value = currentIndex;
}, [currentIndex]);
// TranslateX starts at -SCREEN_WIDTH for feed #1
const translateX = useSharedValue(-SCREEN_WIDTH);
const screenCount = feedTitles.length;
// -- GESTURE HANDLER (Swipe) --
const handleGestureEvent = (event: PanGestureHandlerGestureEvent) => {
const minTranslateX = -(screenCount - 1) * SCREEN_WIDTH;
const maxTranslateX = 0;
translateX.value = Math.min(
maxTranslateX,
Math.max(
minTranslateX,
event.nativeEvent.translationX + currentIndexSV.value * -SCREEN_WIDTH
)
);
};
const handleGestureEnd = () => {
const threshold = SCREEN_WIDTH / 5;
const newIndex =
translateX.value > -(currentIndexSV.value * SCREEN_WIDTH) + threshold
? Math.max(currentIndexSV.value - 1, 0)
: translateX.value < -(currentIndexSV.value * SCREEN_WIDTH) - threshold
? Math.min(currentIndexSV.value + 1, screenCount - 1)
: currentIndexSV.value;
// Update both React state and shared value
setCurrentIndex(newIndex);
currentIndexSV.value = newIndex;
translateX.value = withSpring(newIndex * -SCREEN_WIDTH, {
damping: 20,
stiffness: 150,
mass: 1,
overshootClamping: true,
});
};
// -- TITLE TAPS (Click) --
const handleTitlePress = (index: number) => {
// If you truly want to disable re-tapping the same feed, check against currentIndexSV:
// if (index === currentIndexSV.value) return;
setCurrentIndex(index);
// Immediately update the shared value so the gesture code sees the correct feed
currentIndexSV.value = index;
translateX.value = withSpring(index * -SCREEN_WIDTH, {
damping: 20,
stiffness: 150,
mass: 1,
overshootClamping: true,
});
};
// Animated style that just uses translateX
const animatedFeedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<>
<StatusBar style="light" backgroundColor="black" />
<SafeAreaView className="relative flex-1">
<View className="relative z-20">
<TabsSlidingTitles
titles={feedTitles}
translateX={translateX}
screenWidth={SCREEN_WIDTH}
onTitlePress={handleTitlePress}
/>
</View>
<PanGestureHandler
onGestureEvent={handleGestureEvent}
onEnded={handleGestureEnd}
activeOffsetX={[-20, 20]}
>
<Animated.View
style={animatedFeedStyle}
className={cn(
"flex-1 flex-row",
containerWidth[screenCount as ScreenCount]
)}
>
{FEEDS(user).map((feed, index) => (
<View key={index} className="flex-1 w-screen">
{feed.component || (
<FeedProvider {...feed.providerProps}>
<Feed />
</FeedProvider>
)}
</View>
))}
</Animated.View>
</PanGestureHandler>
</SafeAreaView>
</>
);
};
export default Home;
Code for TabsSlidingTitles:
import React from "react";
import { View, Text, TouchableWithoutFeedback } from "react-native";
import Animated, {
useAnimatedStyle,
interpolate,
SharedValue,
} from "react-native-reanimated";
interface TabsSlidingTitlesProps {
titles: string[];
translateX: SharedValue<number>;
screenWidth: number;
onTitlePress: (index: number) => void; // Add this prop
}
export const TabsSlidingTitles: React.FC<TabsSlidingTitlesProps> = ({
titles,
translateX,
screenWidth,
onTitlePress,
}) => {
// Calculate the total width of the titles including gaps
const titleWidth = screenWidth / 4; // Adjust this to control individual title width
const gap = 16; // Gap between titles
const animatedTitlesStyle = useAnimatedStyle(() => ({
transform: [
{
translateX: interpolate(
translateX.value,
titles.map((_, index) => -index * screenWidth),
titles.map(
(_, index) =>
index * (titleWidth + gap) * -1 +
(screenWidth - titleWidth - gap) / 2
)
),
},
],
}));
return (
<View className="absolute top-0 left-0 right-0">
<View className="relative h-16 justify-center">
{/* Static underline */}
<View
className="absolute bottom-0 left-1/2 -translate-x-1/2 h-1 w-24 rounded-md bg-white/50 z-30"
style={{
shadowColor: "#00000099",
shadowRadius: 2,
shadowOffset: { width: 0, height: 0 },
elevation: 4,
}}
/>
{/* Animated row of titles */}
<Animated.View
style={animatedTitlesStyle}
className="absolute flex-row items-center left-0"
>
{titles.map((title, index) => (
<TouchableWithoutFeedback
key={index}
onPress={() => onTitlePress(index)} // Handle press event
>
<Text
className="text-lg font-bold text-center"
style={{
width: titleWidth,
marginHorizontal: gap / 2,
color: "white",
textShadowColor: "#00000099",
textShadowRadius: 2,
textShadowOffset: { width: 0, height: 0 },
}}
>
{title}
</Text>
</TouchableWithoutFeedback>
))}
</Animated.View>
</View>
</View>
);
};
What Happens
- Swiping between tabs works as expected.
- Clicking on tab titles only works once. After clicking a title, I need to swipe to a new tab before clicking on another title works again.
What I’ve Tried
- Syncing state: I ensured that
currentIndexSV
updates in auseEffect
whenevercurrentIndex
changes. - Avoid redundant clicks: Added a check to skip
handleTitlePress
if the tapped index is the current index.
Desired Behavior
- Clicking on a tab title should reliably navigate to the corresponding tab.
- Both swiping and clicking should work without requiring one to “reset” the other.
What am I missing, or how can I fix this issue?