I struggled with this issue too for a few days. For what I can tell, this issue isn't specific to material top tabs or pagerview. If you place a horizontal scrollview or a flatlist into a vertical scrollview, the horizontal gesture gets sometimes stolen by the parent vertical scrollview.
I noticed that the onResponderTerminate callback gets called on the horizontal scrollview. React native provides a way to prevent the termination by passing the onResponderTerminationRequest={(event) => false} prop but that doesn't seem to do anything. The termination callback gets still called. The first bug reports of this are over 6 years old and I didn't find any working fixes either.
A temporary workaround is to build your own scrollview by using react-native-gesture-handler and react-native-reanimated. Down below is a complete example of how to make one. Please note that Animated has to be imported from react-native-reanimated. If you need to use Animated from react-native, import it by using an alias --> import {Animated as Anim} from 'react-native';
import React from 'react';
import { Animated as Anim, ScrollView, Text, View } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDecay,
withSpring
} from "react-native-reanimated";
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
export const MyTab = () => {
// How many points the view is scrollable.
// In other words, how many points the view overflows.
// You might need to dynamically update this
// This can be calculated by subtracting screen width from the view width.
// If the view doesn't overflow, set this to zero.
const SCROLLABLE_WIDTH = 200;
const animatedX = useSharedValue(0);
const xContext = useSharedValue(0);
const animStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: animatedX.value }]
}
})
const panGesture = React.useMemo(() => Gesture.Pan()
.activeOffsetX([-17, 17]) // 17 is the optimal value. Anything higher might cause a tab change
.activeOffsetY([-22, 22]) // Allows the vertical scrollview to take over when swiping vertically
.maxPointers(1)
.onStart((e) => {
xContext.value = animatedX.value;
})
.onUpdate((e) => {
const target = xContext.value + e.translationX;
if (target > 0) {
animatedX.value = target * 0.3;
}
else if (target < -SCROLLABLE_WIDTH) {
animatedX.value = -SCROLLABLE_WIDTH + (SCROLLABLE_WIDTH + target) * 0.3;
}
else {
animatedX.value = target;
}
})
.onEnd((e) => {
const target = xContext.value + e.translationX;
if (target > 0) {
animatedX.value = withSpring(0, { mass: 0.3, stiffness: 110 });
}
else if (target < -SCROLLABLE_WIDTH) {
animatedX.value = withSpring(-SCROLLABLE_WIDTH, { mass: 0.3, stiffness: 110 });
}
else {
animatedX.value = withDecay({
velocity: e.velocityX,
clamp: [-SCROLLABLE_WIDTH, 0], // optionally define boundaries for the animation
});
}
}),
[] // Set here your useStates required in the gesture
);
return (
<ScrollView style={{}}>
<GestureDetector
gesture={panGesture}
>
{/* If no static container is set, a tab change might initialize
If you need to hide overflow set overflow: "hidden" to the container style */}
<Animated.View style={{ marginVertical: 50 }}>
<Animated.View
style={[{
flexDirection: "row"
},
animStyle
]}>
<Text style={{ color: "#abcdef", fontSize: 28, marginHorizontal: 20 }}>
{"Horizontally\nscrollable\nitem 1"}
</Text>
<Text style={{ color: "#abcdef", fontSize: 28, marginHorizontal: 20 }}>
{"Horizontally\nscrollable\nitem 2"}
</Text>
<Text style={{ color: "#abcdef", fontSize: 28, marginHorizontal: 20 }}>
{"Horizontally\nscrollable\nitem 3"}
</Text>
<Text style={{ color: "#abcdef", fontSize: 28, marginHorizontal: 20 }}>
{"Horizontally\nscrollable\nitem 4"}
</Text>
</Animated.View>
</Animated.View>
</GestureDetector>
</ScrollView>
)
}