Infinite Menu
A Global, Infinite Radial Menu built with React Native Reanimated, Skia, and React Native Gesture Handler
Last updated on
Manual
Install the following dependencies:
npm install @shopify/react-native-skia react-native-reanimated react-native-gesture-handler react-native-workletsCopy and paste the following code into your project.
component/organisms/infinite-menu.tsx
// @ts-checkimport React, { useCallback, useMemo, useState, memo, useEffect } from "react";import { View, StyleSheet, Dimensions, Animated as RNAnimated,} from "react-native";import { Canvas, Circle, Group, Image, Skia } from "@shopify/react-native-skia";import { useSharedValue, useFrameCallback } from "react-native-reanimated";import { Gesture, GestureDetector, GestureHandlerRootView,} from "react-native-gesture-handler";import { useLoadImages } from "./hooks";import { Quat, Vec3 } from "./maths-type";import type { IDisc, IDiscComponent, IInfiniteMenu, IMenuItem } from "./types";import { generateIcosahedronVertices } from "./helpers";import { projectToSphere, quatConjugate, quatFromVectors, quatMultiply, quatNormalize, quatRotateVec3, quatSlerp, vec3Normalize,} from "./maths";import { scheduleOnRN } from "react-native-worklets";const DiscComponent: React.FC<IDiscComponent> = memo<IDiscComponent>( ({ x, y, radius, alpha, image, }: IDiscComponent): React.ReactElement | null => { const clipPath = useMemo(() => { const path = Skia.Path.Make(); path.addCircle(x, y, radius); return path; }, [x, y, radius]); if (radius < 1) return null; if (!image) { return ( <Circle cx={x} cy={y} r={radius} color={`rgba(80, 80, 80, ${alpha})`} /> ); } return ( <Group clip={clipPath} opacity={alpha}> <Image image={image} x={x - radius} y={y - radius} width={radius * 2} height={radius * 2} fit="cover" /> </Group> ); },);export const InfiniteMenu: React.FC<IInfiniteMenu> & React.FunctionComponent<IInfiniteMenu> = memo<IInfiniteMenu>( ({ items, scale = 1, backgroundColor = "#000000" }: IInfiniteMenu) => { const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); const centerX = screenWidth / 2; const centerY = screenHeight / 2; const imageUrls = useMemo( () => items.map<string>((item) => item.image), [items], ); const loadedImages = useLoadImages<string[]>(imageUrls); const [activeItem, setActiveItem] = useState<IMenuItem | null>( items[0] || null, ); const [isMoving, setIsMoving] = useState<boolean>(false); const [discData, setDiscData] = useState<IDisc[]>([]); const SPHERE_RADIUS = 2 * scale; const DISC_BASE_SCALE = 0.25; const CAMERA_Z = 3 * scale; const PROJECTION_SCALE = 150; const sphereVertices = useMemo( () => generateIcosahedronVertices(1, SPHERE_RADIUS), [SPHERE_RADIUS], ); const verticesRef = useMemo(() => [...sphereVertices], [sphereVertices]); const qx = useSharedValue<number>(0); const qy = useSharedValue<number>(0); const qz = useSharedValue<number>(0); const qw = useSharedValue<number>(1); const prx = useSharedValue<number>(0); const pry = useSharedValue<number>(0); const prz = useSharedValue<number>(0); const prw = useSharedValue<number>(1); const rotVelocity = useSharedValue<number>(0); const isDown = useSharedValue<boolean>(false); const prevX = useSharedValue<number>(0); const prevY = useSharedValue<number>(0); const camZ = useSharedValue<number>(CAMERA_Z); const activeIdx = useSharedValue<number>(0); const updateActiveItem = useCallback( (index: number) => { if (items.length === 0) return; const itemIndex = index % items.length; setActiveItem(items[itemIndex]); }, [items], ); const updateIsMoving = useCallback((moving: boolean) => { setIsMoving(moving); }, []); const updateDiscData = useCallback((data: IDisc[]) => { setDiscData(data); }, []); const lastMoving = useSharedValue<boolean>(false); const frameSkip = useSharedValue<number>(0); useFrameCallback((info) => { "worklet"; // Clamp dt to prevent physics jumps after idle const rawDt = info.timeSincePreviousFrame || 16; const dt = Math.min(rawDt, 50); const ts = dt / 16 + 0.0001; const IDENTITY: Quat = { x: 0, y: 0, z: 0, w: 1 }; const orientation: Quat = { x: qx.value, y: qy.value, z: qz.value, w: qw.value, }; const pointerRot: Quat = { x: prx.value, y: pry.value, z: prz.value, w: prw.value, }; const dampIntensity = 0.1 * ts; const dampenedPR = quatSlerp(pointerRot, IDENTITY, dampIntensity); prx.value = dampenedPR.x; pry.value = dampenedPR.y; prz.value = dampenedPR.z; prw.value = dampenedPR.w; let snapRot: Quat = IDENTITY; if (!isDown.value) { const snapDir: Vec3 = { x: 0, y: 0, z: -1 }; const invOrientation = quatConjugate(orientation); const transformedSnapDir = quatRotateVec3(invOrientation, snapDir); let maxDot = -Infinity; let nearestIdx = 0; for (let i = 0; i < verticesRef.length; i++) { const v = verticesRef[i]; const dot = transformedSnapDir.x * v.x + transformedSnapDir.y * v.y + transformedSnapDir.z * v.z; if (dot > maxDot) { maxDot = dot; nearestIdx = i; } } const nearestV = verticesRef[nearestIdx]; const worldV = quatRotateVec3(orientation, nearestV); const targetDir = vec3Normalize(worldV); const sqrDist = (targetDir.x - snapDir.x) ** 2 + (targetDir.y - snapDir.y) ** 2 + (targetDir.z - snapDir.z) ** 2; const distFactor = Math.max(0.1, 1 - sqrDist * 10); const snapIntensity = 0.2 * ts * distFactor; snapRot = quatFromVectors(targetDir, snapDir, snapIntensity); const itemLen = Math.max(1, items.length); const itemIdx = nearestIdx % itemLen; if (activeIdx.value !== itemIdx) { activeIdx.value = itemIdx; scheduleOnRN(updateActiveItem, itemIdx); } } const combined = quatMultiply(snapRot, dampenedPR); const newOrientation = quatNormalize(quatMultiply(combined, orientation)); qx.value = newOrientation.x; qy.value = newOrientation.y; qz.value = newOrientation.z; qw.value = newOrientation.w; const rad = Math.acos(Math.min(1, Math.max(-1, combined.w))) * 2; const rv = rad / (2 * Math.PI); rotVelocity.value += (rv - rotVelocity.value) * 0.5 * ts; const targetZ = isDown.value ? CAMERA_Z + rotVelocity.value * 80 + 2.5 : CAMERA_Z; const damping = isDown.value ? 7 / ts : 5 / ts; camZ.value += (targetZ - camZ.value) / damping; const moving = isDown.value || Math.abs(rotVelocity.value) > 0.005; if (moving !== lastMoving.value) { lastMoving.value = moving; scheduleOnRN(updateIsMoving, moving); } if (!moving && !isDown.value && Math.abs(rotVelocity.value) < 0.001) { frameSkip.value++; if (frameSkip.value > 5) { return; } } else { frameSkip.value = 0; } const discs: IDisc[] = []; const currentCamZ = camZ.value; const itemLen = Math.max(1, items.length); for (let i = 0; i < verticesRef.length; i++) { const v = verticesRef[i]; const worldPos = quatRotateVec3(newOrientation, v); const perspective = currentCamZ / (currentCamZ - worldPos.z); const sx = centerX + worldPos.x * perspective * PROJECTION_SCALE; const sy = centerY - worldPos.y * perspective * PROJECTION_SCALE; const zFactor = (Math.abs(worldPos.z) / SPHERE_RADIUS) * 0.6 + 0.4; const baseRadius = zFactor * DISC_BASE_SCALE * perspective * PROJECTION_SCALE; const alpha = Math.max(0.1, (worldPos.z / SPHERE_RADIUS) * 0.45 + 0.55); discs.push({ screenX: sx, screenY: sy, radius: baseRadius, alpha: alpha, z: worldPos.z, itemIndex: i % itemLen, }); } discs.sort((a, b) => a.z - b.z); scheduleOnRN(updateDiscData, discs); }); const panGesture = Gesture.Pan() .onBegin((e) => { "worklet"; prevX.value = e.x; prevY.value = e.y; isDown.value = true; }) .onUpdate((e) => { "worklet"; const intensity = 0.3; const amplification = 5; const midX = prevX.value + (e.x - prevX.value) * intensity; const midY = prevY.value + (e.y - prevY.value) * intensity; const dx = midX - prevX.value; const dy = midY - prevY.value; if (dx * dx + dy * dy > 0.1) { const p = projectToSphere(midX, midY); const q = projectToSphere(prevX.value, prevY.value); const newRot = quatFromVectors(p, q, amplification); prx.value = newRot.x; pry.value = newRot.y; prz.value = newRot.z; prw.value = newRot.w; prevX.value = midX; prevY.value = midY; } }) .onEnd(() => { "worklet"; isDown.value = false; }) .onFinalize(() => { "worklet"; isDown.value = false; }); const fadeAnim = useMemo<RNAnimated.Value>( () => new RNAnimated.Value(1), [], ); const scaleAnim = useMemo<RNAnimated.Value>( () => new RNAnimated.Value(1), [], ); useEffect(() => { RNAnimated.parallel([ RNAnimated.timing(fadeAnim, { toValue: isMoving ? 0 : 1, duration: isMoving ? 100 : 500, useNativeDriver: true, }), RNAnimated.timing(scaleAnim, { toValue: isMoving ? 0 : 1, duration: isMoving ? 100 : 500, useNativeDriver: true, }), ]).start(); }, [isMoving, fadeAnim, scaleAnim]); return ( <GestureHandlerRootView style={[ styles.container, { backgroundColor, }, ]} > <View style={styles.container}> <GestureDetector gesture={panGesture}> <Canvas style={styles.canvas}> {discData.map((disc, idx) => ( <DiscComponent key={`disc-${idx}`} x={disc.screenX} y={disc.screenY} radius={disc.radius} alpha={disc.alpha} image={loadedImages[disc.itemIndex] || null} /> ))} </Canvas> </GestureDetector> </View> </GestureHandlerRootView> ); },);const styles = StyleSheet.create({ container: { flex: 1, }, canvas: { flex: 1, },});export default memo< React.FC<IInfiniteMenu> & React.FunctionComponent<IInfiniteMenu>>(InfiniteMenu);export type { IMenuItem, IInfiniteMenu, IDisc, IDiscComponent };Usage
import { View, StyleSheet, StatusBar } from "react-native";import React, { type ReactElement } from "react";import InfiniteMenu, { type IMenuItem,} from "@/components/organisms/infinite-menu";const MENU_ITEMS: IMenuItem[] = [ { image: "https://i.pinimg.com/736x/22/1e/ae/221eae1af669db2d93cc2155c74371ff.jpg", }, { image: "https://i.pinimg.com/736x/2c/d9/66/2cd96620a3a595e3e80e5ddf364fa162.jpg", }, { image: "https://i.pinimg.com/736x/83/49/c2/8349c22cc5c73a6eddbf561a41c09fda.jpg", }, { image: "https://i.pinimg.com/736x/08/0f/3c/080f3c1e3b8d4a4c020e72ed8ebe982b.jpg", },];export default function App<T extends React.FC>(): React.JSX.Element & ReactElement { return ( <> <StatusBar barStyle="dark-content" backgroundColor="#000" /> <View style={styles.container}> <InfiniteMenu items={MENU_ITEMS} style={styles.menuContainer} /> </View> </> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", }, menuContainer: { width: "100%", height: "100%", },});Props
IMenuItem
React Native Reanimated
React Native Skia
React Native Gesture Handler
