* Copyright (c) 2025 Huawei Technologies Co., Ltd.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
ActivityIndicator,
Animated,
NativeScrollEvent,
NativeSyntheticEvent,
PanResponder,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
Pressable,
} from 'react-native';
import {TestSuite} from '@rnoh/testerino';
import {useRef, useState} from 'react';
import {TestCase} from '../components';
import React from 'react';
export const PanResponderTest = () => {
return (
<TestSuite name="PanResponder">
<TestCase.Logical
itShould="create PanResponder"
fn={({expect}) => {
expect(PanResponder.create({})).to.be.not.empty;
}}
/>
<TestCase.Example modal itShould="allow panning inside ScrollView">
<PanResponderInScrollView />
</TestCase.Example>
<TestCase.Example
modal
itShould="allow panning inside ScrollView with refreshControl">
<PanResponderInScrollViewWithRefresh />
</TestCase.Example>
<TestCase.Example
modal
itShould="allow panning outside ScrollView without affecting its scrolling behavior">
<ScrollViewNestedInPanResponder />
</TestCase.Example>
</TestSuite>
);
};
const PanResponderInScrollView = () => {
const pan = useRef(new Animated.ValueXY()).current;
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => {
return true;
},
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: Animated.event([null, {dx: pan.x, dy: pan.y}], {
useNativeDriver: false,
}),
onPanResponderRelease: () => {
pan.extractOffset();
},
onShouldBlockNativeResponder: () => {
return true;
},
}),
).current;
return (
<ScrollView style={styles.scrollview} horizontal pagingEnabled>
<View style={[styles.base, styles.view1]}>
<Animated.View
style={{
transform: [{translateX: pan.x}, {translateY: pan.y}],
}}
{...panResponder.panHandlers}>
<View style={styles.box} />
</Animated.View>
</View>
<View style={[styles.base, styles.view2]} />
<View style={[styles.base, styles.view1]} />
<View style={[styles.base, styles.view2]} />
</ScrollView>
);
};
const PanResponderInScrollViewWithRefresh = () => {
const pan = useRef(new Animated.ValueXY()).current;
const [refreshing, setRefreshing] = React.useState(false);
const onRefresh = React.useCallback(() => {
setRefreshing(true);
setTimeout(() => {
setRefreshing(false);
}, 2000);
}, []);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => {
return true;
},
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: Animated.event([null, {dx: pan.x, dy: pan.y}], {
useNativeDriver: false,
}),
onPanResponderRelease: () => {
pan.extractOffset();
},
onShouldBlockNativeResponder: () => {
return true;
},
}),
).current;
return (
<ScrollView
style={styles.scrollview}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
pagingEnabled>
<View style={[styles.base, styles.view1]}>
<Animated.View
style={{
transform: [{translateX: pan.x}, {translateY: pan.y}],
}}
{...panResponder.panHandlers}>
<View style={styles.box} />
</Animated.View>
</View>
<View style={[styles.base, styles.view2]} />
<View style={[styles.base, styles.view1]} />
<View style={[styles.base, styles.view2]} />
</ScrollView>
);
};
const ScrollViewNestedInPanResponder = () => {
const [refreshing, setRefreshing] = useState(false);
const scrollOffsetY = useRef(0);
const [time, setTime] = useState(0);
const headerHeight = 80;
const pan = useRef(new Animated.Value(0)).current;
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => {
return scrollOffsetY.current <= 0;
},
onMoveShouldSetPanResponder: (_, gestureState) => {
return scrollOffsetY.current <= 0 && gestureState.dy >= 0;
},
onPanResponderMove: (_, gestureState) => {
if (!refreshing) {
const dy = Math.min(gestureState.dy * 0.5, headerHeight * 2.5);
pan.setValue(dy);
}
},
onPanResponderRelease: (_, gestureState) => {
if (gestureState.dy > headerHeight && !refreshing) {
handleRefresh();
}
resetAnimation();
},
onPanResponderTerminate: () => resetAnimation(),
}),
).current;
const handleRefresh = async (): Promise<void> => {
if (refreshing) {
return;
}
setRefreshing(true);
setTimeout(() => {
setRefreshing(false);
}, 1000);
};
const resetAnimation = (): void => {
Animated.spring(pan, {
toValue: 0,
friction: 8,
useNativeDriver: true,
}).start(() => {
pan.setValue(0);
});
};
const rotate = pan.interpolate({
inputRange: [0, headerHeight * 2.5],
outputRange: ['0deg', '180deg'],
});
const handleScroll = (
event: NativeSyntheticEvent<NativeScrollEvent>,
): void => {
scrollOffsetY.current = event.nativeEvent.contentOffset.y;
};
return (
<View style={styles.container}>
<Animated.View
style={[
styles.header,
{
height: headerHeight,
transform: [{translateY: pan}],
},
]}>
<Animated.View style={{transform: [{rotate}], backgroundColor: 'red'}}>
{refreshing ? (
<ActivityIndicator size="large" color="#000" />
) : (
<Text>{'⬇️ Pull down to refresh'}</Text>
)}
</Animated.View>
</Animated.View>
<Animated.View
style={{
transform: [{translateY: pan}],
backgroundColor: 'yellow',
}}
{...panResponder.panHandlers}>
<Animated.ScrollView
onScroll={handleScroll}
onTouchStart={() => {
setTime(time + 1);
}}>
<Pressable style={{backgroundColor: 'lightgreen', height: 1000}}>
<Text style={{marginTop: 100, textAlign: 'center'}}>
onTouchStart: {time}
</Text>
</Pressable>
</Animated.ScrollView>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
view1: {
backgroundColor: 'pink',
},
view2: {
backgroundColor: 'powderblue',
},
base: {
height: 400,
width: 300,
},
scrollview: {
borderWidth: 2,
borderColor: 'black',
height: 400,
width: 300,
},
box: {
height: 100,
width: 100,
backgroundColor: 'blue',
borderRadius: 5,
},
container: {
height: 400,
width: 300,
backgroundColor: '#fff',
overflow: 'hidden',
},
header: {
position: 'absolute',
left: 0,
right: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 1,
},
});