I’m working on a React Native app with a grid of bubbles that can be panned and centered. I’m using react-native-gesture-handler and react-native-reanimated, but I’m having issues with the centering logic.
I’m developing a React Native app with a pannable grid of bubbles. After panning, I’m trying to select the bubble closest to the screen’s center, but my findMostCenteredBubble function is not working as expected.
The findMostCenteredBubble function is selecting bubbles that are visually far from the center instead of the bubble that appears most centered on the screen.
import React, {useState, useRef, useEffect} from 'react';
import {
View,
TouchableOpacity,
Text,
StyleSheet,
Dimensions,
} from 'react-native';
import Animated, {
ZoomIn,
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import {
GestureHandlerRootView,
GestureDetector,
Gesture,
} from 'react-native-gesture-handler';
const bubbles = Array.from({length: 60}, (_, i) => ({
id: i + 1,
label: `Bubble ${i + 1}`,
}));
interface BubblePosition {
id: number;
x: number;
y: number;
}
const BUBBLE_SIZE = 80;
const BUBBLE_MARGIN = 10;
const BUBBLES_PER_ROW = 6;
const {width: SCREEN_WIDTH, height: SCREEN_HEIGHT} = Dimensions.get('window');
const BubbleEffect = () => {
const [selectedBubble, setSelectedBubble] = useState<number | null>(null);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const offsetX = useSharedValue(0);
const offsetY = useSharedValue(0);
const totalWidth = BUBBLES_PER_ROW * (BUBBLE_SIZE + BUBBLE_MARGIN * 2);
const totalHeight =
Math.ceil(bubbles.length / BUBBLES_PER_ROW) *
(BUBBLE_SIZE + BUBBLE_MARGIN * 2);
const initialOffsetX = (SCREEN_WIDTH - totalWidth) / 2;
const initialOffsetY = (SCREEN_HEIGHT - totalHeight) / 2;
console.log('Screen Dimensions:', {SCREEN_WIDTH, SCREEN_HEIGHT});
console.log('Grid Dimensions:', {totalWidth, totalHeight});
console.log('Initial Offset:', {initialOffsetX, initialOffsetY});
const bubblePositions: BubblePosition[] = bubbles.map((_, index) => ({
id: index + 1,
x: (index % BUBBLES_PER_ROW) * (BUBBLE_SIZE + BUBBLE_MARGIN * 2),
y: Math.floor(index / BUBBLES_PER_ROW) * (BUBBLE_SIZE + BUBBLE_MARGIN * 2),
}));
const handleBubblePress = (id: number) => {
setSelectedBubble(id);
centerBubble(id);
};
const findMostCenteredBubble = () => {
'worklet';
const centerX = SCREEN_WIDTH / 2;
const centerY = SCREEN_HEIGHT / 2;
console.log('Screen Center:', {centerX, centerY});
console.log('Current Translation:', {
x: translateX.value,
y: translateY.value,
});
return bubblePositions.reduce(
(closest, bubble) => {
const bubbleCenterX =
bubble.x + translateX.value + BUBBLE_SIZE / 2 + initialOffsetX;
const bubbleCenterY =
bubble.y + translateY.value + BUBBLE_SIZE / 2 + initialOffsetY;
const distance = Math.sqrt(
Math.pow(bubbleCenterX - centerX, 2) +
Math.pow(bubbleCenterY - centerY, 2),
);
console.log(`Bubble ${bubble.id}:`, {
centerX: bubbleCenterX,
centerY: bubbleCenterY,
distance,
});
if (distance < closest.distance) {
return {id: bubble.id, distance};
}
return closest;
},
{id: -1, distance: Infinity},
);
};
const centerBubble = (bubbleId: number) => {
'worklet';
const bubble = bubblePositions.find(b => b.id === bubbleId);
if (bubble) {
const targetX =
SCREEN_WIDTH / 2 - (bubble.x + BUBBLE_SIZE / 2 + initialOffsetX);
const targetY =
SCREEN_HEIGHT / 2 - (bubble.y + BUBBLE_SIZE / 2 + initialOffsetY);
console.log('Centering Bubble:', {id: bubbleId, targetX, targetY});
translateX.value = withSpring(targetX);
translateY.value = withSpring(targetY);
}
};
const panGesture = Gesture.Pan()
.onStart(() => {
offsetX.value = translateX.value;
offsetY.value = translateY.value;
})
.onUpdate(event => {
translateX.value = offsetX.value + event.translationX;
translateY.value = offsetY.value + event.translationY;
})
.onEnd(() => {
console.log('Pan Gesture Ended');
const mostCentered = findMostCenteredBubble();
if (mostCentered.id !== -1) {
console.log('Most Centered Bubble:', mostCentered);
centerBubble(mostCentered.id);
runOnJS(setSelectedBubble)(mostCentered.id);
}
});
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [
{translateX: translateX.value},
{translateY: translateY.value},
],
};
});
// Initial centering
useEffect(() => {
const initialCenterId = Math.ceil(bubbles.length / 2);
console.log('Initial Centering:', initialCenterId);
centerBubble(initialCenterId);
setSelectedBubble(initialCenterId);
}, []);
return (
<GestureHandlerRootView style={styles.safeArea}>
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.scrollContainer, animatedStyle]}>
<View
style={[
styles.container,
{
width: totalWidth,
height: totalHeight,
left: initialOffsetX,
top: initialOffsetY,
},
]}>
{bubblePositions.map(bubble => (
<TouchableOpacity
key={bubble.id}
onPress={() => handleBubblePress(bubble.id)}
activeOpacity={0.7}
style={[
styles.bubbleWrapper,
{
left: bubble.x,
top: bubble.y,
},
]}>
<Animated.View
entering={ZoomIn.duration(300)}
style={[
styles.bubble,
selectedBubble === bubble.id && styles.selectedBubble,
]}>
<Text style={styles.label}>
{bubbles[bubble.id - 1].label}
</Text>
</Animated.View>
</TouchableOpacity>
))}
</View>
</Animated.View>
</GestureDetector>
</GestureHandlerRootView>
);
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#fff',
},
scrollContainer: {
flex: 1,
},
container: {
position: 'absolute',
},
bubbleWrapper: {
position: 'absolute',
width: BUBBLE_SIZE + BUBBLE_MARGIN * 2,
height: BUBBLE_SIZE + BUBBLE_MARGIN * 2,
justifyContent: 'center',
alignItems: 'center',
},
bubble: {
width: BUBBLE_SIZE,
height: BUBBLE_SIZE,
borderRadius: BUBBLE_SIZE / 2,
backgroundColor: '#D3D3D3',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 5,
shadowOffset: {width: 2, height: 2},
},
selectedBubble: {
backgroundColor: '#87CEEB',
},
label: {
color: '#000',
fontSize: 14,
fontWeight: 'bold',
},
});
export default BubbleEffect;