I tried similar multiple circle selection.
import React, { useEffect, useState } from 'react';
import {
View,
StyleSheet,
Text,
Animated,
TouchableOpacity,
Dimensions,
} from 'react-native';
const SCREEN_WIDTH = Dimensions.get('window').width;
const SCREEN_HEIGHT = Dimensions.get('window').height;
type Circle = {
id: number;
radius: number;
label: string;
position: { x: number; y: number };
animatedRadius: Animated.Value;
animatedPosition: Animated.ValueXY;
color: string;
textColor: string;
isSelected: boolean;
};
const calculateRadius = (text: string) => {
// Basic formula to calculate radius based on text length (can be adjusted)
return Math.max(40, text.length * 8);
};
const App = () => {
const [circles, setCircles] = useState<Circle[]>([]);
const handleCollision = (updatedCircles: Circle[]) => {
const padding = 10;
for (let i = 0; i < updatedCircles.length; i++) {
for (let j = i + 1; j < updatedCircles.length; j++) {
const distX = updatedCircles[j].position.x - updatedCircles[i].position.x;
const distY = updatedCircles[j].position.y - updatedCircles[i].position.y;
const distance = Math.sqrt(distX * distX + distY * distY);
const minDist = updatedCircles[i].radius + updatedCircles[j].radius + padding;
if (distance < minDist) {
const angle = Math.atan2(distY, distX);
const overlap = minDist - distance;
updatedCircles[j].position.x += overlap * Math.cos(angle);
updatedCircles[j].position.y += overlap * Math.sin(angle);
// Apply animation
Animated.spring(updatedCircles[j].animatedPosition, {
toValue: { x: updatedCircles[j].position.x, y: updatedCircles[j].position.y },
useNativeDriver: false,
}).start();
}
}
}
};
useEffect(() => {
const createCircles = () => {
const randomPositions = () => ({
x: Math.random() * SCREEN_WIDTH * 0.8 + SCREEN_WIDTH * 0.1,
y: Math.random() * SCREEN_HEIGHT * 0.6 + SCREEN_HEIGHT * 0.2,
});
const labels = [
'Raf Simons', 'Maison Margiela', 'Versace', 'Fendi', 'Prada', 'Burberry',
'Hilfiger', 'Stussy', 'Reebok', 'The North Face', 'Visvim'
];
const newCircles: Circle[] = labels.map((label, index) => {
const radius = calculateRadius(label); // Calculate radius based on text length
return {
id: index,
label,
radius,
position: randomPositions(),
animatedRadius: new Animated.Value(0), // Initial radius for loading animation
animatedPosition: new Animated.ValueXY({ x: SCREEN_WIDTH / 2, y: SCREEN_HEIGHT / 2 }), // Start at center for animation
color: 'white',
textColor: 'black',
isSelected: false,
};
});
setCircles(newCircles);
// Animate circles on load
newCircles.forEach((circle, index) => {
setTimeout(() => {
Animated.spring(circle.animatedRadius, {
toValue: circle.radius,
useNativeDriver: false,
}).start();
Animated.timing(circle.animatedPosition, {
toValue: { x: circle.position.x, y: circle.position.y },
duration: 800,
useNativeDriver: false,
}).start();
}, index * 100); // Stagger animations for each circle
});
// Handle collisions
setTimeout(() => {
handleCollision(newCircles);
}, 1000);
};
createCircles();
}, []);
// Handle circle press (toggle selection and animate)
const handleCirclePress = (circleId: number) => {
setCircles((prevCircles) =>
prevCircles.map((circle) => {
if (circle.id === circleId) {
const isSelected = !circle.isSelected;
// Console the selected circle text
if (isSelected) {
console.log(`Selected circle: ${circle.label}`);
}
// Animate size on selection
Animated.timing(circle.animatedRadius, {
toValue: isSelected ? circle.radius * 1.2 : circle.radius,
duration: 300,
useNativeDriver: false,
}).start();
return {
...circle,
color: isSelected ? 'black' : 'white',
textColor: isSelected ? 'white' : 'black',
isSelected,
};
}
return circle;
})
);
};
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerText}>Choose three or more favorites</Text>
</View>
{/* Circles */}
<View style={styles.circleContainer}>
{circles.map((circle) => (
<Animated.View
key={circle.id}
style={[
styles.circle,
{
width: circle.animatedRadius,
height: circle.animatedRadius,
borderRadius: circle.animatedRadius,
backgroundColor: circle.color,
transform: circle.animatedPosition.getTranslateTransform(),
},
]}
>
<TouchableOpacity
onPress={() => handleCirclePress(circle.id)}
style={styles.circleTouchable}
>
<Text style={[styles.circleText, { color: circle.textColor }]}>
{circle.label}
</Text>
</TouchableOpacity>
</Animated.View>
))}
</View>
{/* Footer */}
<View style={styles.footer}>
<TouchableOpacity style={styles.button}>
<Text style={styles.buttonText}>Load More</Text>
</TouchableOpacity>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
alignItems: 'center',
},
header: {
paddingTop: 50,
paddingBottom: 20,
},
headerText: {
fontSize: 18,
fontWeight: 'bold',
color: 'black',
},
circleContainer: {
flex: 1,
width: '100%',
position: 'relative',
},
circle: {
position: 'absolute',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: 'black',
},
circleTouchable: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
circleText: {
fontSize: 12,
fontWeight: 'bold',
textAlign: 'center',
},
footer: {
paddingBottom: 20,
},
button: {
backgroundColor: 'black',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 20,
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
});
export default App;
And another logic as below
import React, { useEffect, useState, useRef } from 'react';
import {
View,
StyleSheet,
Text,
Animated,
TouchableOpacity,
Dimensions,
} from 'react-native';
const SCREEN_WIDTH = Dimensions.get('window').width;
const SCREEN_HEIGHT = Dimensions.get('window').height;
const defaultOptions = {
size: 80, // Reduce the size for better mobile adaptation
minSize: 20,
gutter: 16,
numCols: 3, // Limit the number of columns for mobile
yRadius: 200,
xRadius: 200,
};
// Define Circle type
interface Circle {
id: number;
label: string;
radius: number;
animatedRadius: Animated.Value;
animatedPosition: Animated.ValueXY;
color: string;
textColor: string;
isSelected: boolean;
position: {
x: number;
y: number;
};
}
const App = () => {
const [circles, setCircles] = useState<Circle[]>([]);
const scrollableRef = useRef(null);
useEffect(() => {
const labels = [
'Raf Simons', 'Maison Margiela', 'Versace', 'Fendi', 'Prada',
'Burberry', 'Hilfiger', 'Stussy', 'Reebok', 'The North Face',
'Visvim',
];
const createCircles = () => {
const newCircles: Circle[] = [];
labels.forEach((label, index) => {
const position = calculatePosition(index, labels.length);
const animatedRadius = new Animated.Value(0);
const animatedPosition = new Animated.ValueXY({ x: position.x, y: position.y });
newCircles.push({
id: index,
label,
radius: defaultOptions.size,
animatedRadius,
animatedPosition,
color: 'white',
textColor: 'black',
isSelected: false,
position,
});
});
setCircles(newCircles);
// Animate circles on load
newCircles.forEach((circle, index) => {
setTimeout(() => {
Animated.spring(circle.animatedRadius, {
toValue: circle.radius,
useNativeDriver: false,
}).start();
Animated.timing(circle.animatedPosition, {
toValue: { x: circle.position.x, y: circle.position.y },
duration: 800,
useNativeDriver: false,
}).start();
}, index * 100); // Stagger animations for each circle
});
};
createCircles();
}, []);
const calculatePosition = (index: number, total: number) => {
const numCols = Math.min(defaultOptions.numCols, total);
const row = Math.floor(index / numCols);
const col = index % numCols;
// Calculate the size and gutter based on screen width
const circleSize = defaultOptions.size;
const gutter = defaultOptions.gutter;
const xOffset = (circleSize + gutter) * col + (SCREEN_WIDTH - (numCols * circleSize + (numCols - 1) * gutter)) / 2;
const yOffset = (circleSize + gutter) * row;
return {
x: xOffset,
y: yOffset,
};
};
const handleCirclePress = (circleId: number) => {
setCircles((prevCircles) =>
prevCircles.map((circle) => {
if (circle.id === circleId) {
const isSelected = !circle.isSelected;
if (isSelected) {
console.log(`Selected circle: ${circle.label}`);
}
Animated.timing(circle.animatedRadius, {
toValue: isSelected ? circle.radius * 1.2 : circle.radius,
duration: 300,
useNativeDriver: false,
}).start();
return {
...circle,
color: isSelected ? 'black' : 'white',
textColor: isSelected ? 'white' : 'black',
isSelected,
};
}
return circle;
})
);
};
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerText}>Choose three or more favorites</Text>
</View>
{/* Circles */}
<View style={styles.circleContainer}>
{circles.map((circle) => (
<Animated.View
key={circle.id}
style={[
styles.circle,
{
width: circle.animatedRadius,
height: circle.animatedRadius,
borderRadius: circle.animatedRadius,
backgroundColor: circle.color,
position: 'absolute', // Make position absolute for correct placement
left: circle.animatedPosition.x, // Use animated position for x
top: circle.animatedPosition.y, // Use animated position for y
},
]}
>
<TouchableOpacity
onPress={() => handleCirclePress(circle.id)}
style={styles.circleTouchable}
>
<Text style={[styles.circleText, { color: circle.textColor }]}>
{circle.label}
</Text>
</TouchableOpacity>
</Animated.View>
))}
</View>
{/* Footer */}
<View style={styles.footer}>
<TouchableOpacity style={styles.button}>
<Text style={styles.buttonText}>Load More</Text>
</TouchableOpacity>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
alignItems: 'center',
},
header: {
paddingTop: 50,
paddingBottom: 20,
},
headerText: {
fontSize: 18,
fontWeight: 'bold',
color: 'black',
},
circleContainer: {
flex: 1,
width: '100%',
position: 'relative',
},
circle: {
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: 'black',
},
circleTouchable: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
circleText: {
fontSize: 12,
fontWeight: 'bold',
textAlign: 'center',
},
footer: {
paddingBottom: 20,
},
button: {
backgroundColor: 'black',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 20,
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
});
export default App;