├── .github └── resources │ ├── ellipse_y.gif │ ├── open_close.gif │ ├── rotate.gif │ ├── rotate_velocity.gif │ └── simple.gif ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── _config.yaml ├── example └── expo │ ├── .gitignore │ ├── App.js │ ├── app.json │ ├── assets │ ├── adaptive-icon.png │ ├── bubble.png │ ├── burger.png │ ├── cocktail.png │ ├── cup.png │ ├── favicon.png │ ├── ice-cream.png │ ├── icon.png │ ├── pizza.png │ └── splash.png │ ├── babel.config.js │ ├── metro.config.js │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── Complex.js │ └── Minimal.js │ ├── tsconfig.json │ └── yarn.lock ├── index.ts ├── package-lock.json ├── package.json ├── src ├── CircularLayout.tsx ├── constants.ts └── types.d.ts └── tsconfig.json /.github/resources/ellipse_y.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/.github/resources/ellipse_y.gif -------------------------------------------------------------------------------- /.github/resources/open_close.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/.github/resources/open_close.gif -------------------------------------------------------------------------------- /.github/resources/rotate.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/.github/resources/rotate.gif -------------------------------------------------------------------------------- /.github/resources/rotate_velocity.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/.github/resources/rotate_velocity.gif -------------------------------------------------------------------------------- /.github/resources/simple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/.github/resources/simple.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | 11 | # Native 12 | *.orig.* 13 | *.jks 14 | *.p8 15 | *.p12 16 | *.key 17 | *.mobileprovision 18 | 19 | # Metro 20 | .metro-health-check* 21 | 22 | # debug 23 | npm-debug.* 24 | yarn-debug.* 25 | yarn-error.* 26 | 27 | # macOS 28 | .DS_Store 29 | *.pem 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | 38 | # example 39 | example/node_modules 40 | *.log 41 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example/* 2 | .gitignore 3 | babel.config.js 4 | metro.config.js 5 | jest.config.js 6 | *.log 7 | .github 8 | _config.yaml 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Iordanis Sapidis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | react-native-circular-layout 2 | ========================= 3 | 4 | [![npm version](https://img.shields.io/npm/v/react-native-circular-layout)](https://www.npmjs.com/package/react-native-circular-layout) 5 | ![license](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square) 6 | [![GitHub](https://img.shields.io/badge/GitHub-Repository-blue)](https://github.com/IordanisSap/react-native-circular-layout/) 7 | 8 | A flexible React Native component that arranges its children in a circular or elliptical layout.
9 | It supports touch gesture rotation, animations, and includes a variety of extra features. 10 | 11 | 12 |
13 | open_close 14 | rotate_velocity 15 | rotate 16 |
17 | 18 | ## Table of Contents 19 | - [Installation](#installation) 20 | - [Dependencies](#dependencies) 21 | - [Examples](#examples) 22 | - [Props](#props) 23 | 24 | ## Installation 25 | 26 | With npm: 27 | 28 | ``` 29 | npm install react-native-circular-layout 30 | ``` 31 | 32 | With yarn: 33 | 34 | ``` 35 | yarn add react-native-circular-layout 36 | ``` 37 | 38 | IMPORTANT: This library uses react-native-gesture-handler. To ensure proper functionality, you need to wrap your app's root component with `GestureHandlerRootView`. 39 | 40 | 41 | 42 | 43 | ## Dependencies 44 | 45 | ``` json 46 | { 47 | "react": "18.2.0", 48 | "react-native": "0.74.3", 49 | "react-native-gesture-handler": "~2.16.1", 50 | "react-native-reanimated": "~3.10.1" 51 | } 52 | ``` 53 | 54 | ## Examples 55 | 56 | Minimal example
57 | rotate 58 | 59 | ``` 60 | import { SafeAreaView, View } from 'react-native'; 61 | import CircularView from 'react-native-circular-layout'; 62 | import { GestureHandlerRootView } from 'react-native-gesture-handler'; 63 | 64 | export default function Example() { 65 | 66 | const colors = ['#D2691E', '#FFA500', '#FF69B4', '#FFB6C1', '#8B4513']; 67 | return ( 68 | 69 | 70 | 71 | 76 | {colors.map((color, index) => ( 77 | 79 | ))} 80 | 81 | 82 | 83 | 84 | ); 85 | } 86 | 87 | ``` 88 | 89 | [More examples](./example/) 90 | 91 | 92 | ## Props 93 | 94 | | Prop Name | Type | Default | Description | 95 | |------------------------|-------------------------------------------------|-------------------|-----------------------------------------------------------------------------------------------------| 96 | | `children(required)` | `React.ReactNode` | - | The children to be displayed in the circular layout | 97 | | `radiusX(required)` | `number` \| `SharedValue` | - | The radius of the ellipse in the x direction. Can be a number or a shared value | 98 | | `radiusY(required)` | `number` \| `SharedValue` | - | The radius of the ellipse in the y direction. Can be a number or a shared value | 99 | | `centralComponent` | `React.ReactNode` | `null` | The central component to be displayed in the center of the circle/ellipse | 100 | | `index` | `number` | `0` | The index of the child that is currently snapped to the top | 101 | | `snappingEnabled` | `boolean` | `true` | Whether the view should snap to the nearest child | 102 | | `onSnap` | `(index: number) => void` | - | Called when the view snaps to a child. Called when the animation ends. | 103 | | `onSnapStart` | `(index: number) => void` | - | Called when the snapping animation starts | 104 | | `snapAngle` | `SnapAngle` \| `number` | `SnapAngle.Top (-Math.PI / 2)` | The angle in radians at which the view should snap to the nearest child | 105 | | `gesturesEnabled` | `boolean` | `true` | Whether the user can pan the view / rotate using touch | 106 | | `onGestureStart` | `() => void` | - | Called when the user starts a gesture | 107 | | `onGestureEnd` | `() => void` | - | Called when the user ends a gesture | 108 | | `rotateCentralComponent`| `boolean` | `false` | Whether the central component should rotate with the rest of the components | 109 | | `childContainerStyle` | `any` | - | The style of the container for the children | 110 | | `snapDuration` | `number` | `600ms` | The duration of the snapping animation in ms | 111 | | `animationConfig` | `DecayConfig` | - | The configuration for the decay animation. Note: deceleration values below 0.9 will cause the animation to stop almost immediately | 112 | 113 | 114 | -------------------------------------------------------------------------------- /_config.yaml: -------------------------------------------------------------------------------- 1 | author: Iordanis Sapidis 2 | webmaster_verifications: 3 | google: rPU9NHFS0ZkKm4WDA5EneEkAtylQLO1E2K9QElnpKRk 4 | -------------------------------------------------------------------------------- /example/expo/.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | 11 | # Native 12 | *.orig.* 13 | *.jks 14 | *.p8 15 | *.p12 16 | *.key 17 | *.mobileprovision 18 | 19 | # Metro 20 | .metro-health-check* 21 | 22 | # debug 23 | npm-debug.* 24 | yarn-debug.* 25 | yarn-error.* 26 | 27 | # macOS 28 | .DS_Store 29 | *.pem 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /example/expo/App.js: -------------------------------------------------------------------------------- 1 | import Complex from "./src/Complex"; 2 | import Minimal from "./src/Minimal"; 3 | 4 | export default function App() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /example/expo/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "react-native-circular-layout-expo-example", 4 | "slug": "react-native-circular-layout-expo-example", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "ios": { 15 | "supportsTablet": true 16 | }, 17 | "android": { 18 | "adaptiveIcon": { 19 | "foregroundImage": "./assets/adaptive-icon.png", 20 | "backgroundColor": "#ffffff" 21 | } 22 | }, 23 | "web": { 24 | "favicon": "./assets/favicon.png" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /example/expo/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/example/expo/assets/adaptive-icon.png -------------------------------------------------------------------------------- /example/expo/assets/bubble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/example/expo/assets/bubble.png -------------------------------------------------------------------------------- /example/expo/assets/burger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/example/expo/assets/burger.png -------------------------------------------------------------------------------- /example/expo/assets/cocktail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/example/expo/assets/cocktail.png -------------------------------------------------------------------------------- /example/expo/assets/cup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/example/expo/assets/cup.png -------------------------------------------------------------------------------- /example/expo/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/example/expo/assets/favicon.png -------------------------------------------------------------------------------- /example/expo/assets/ice-cream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/example/expo/assets/ice-cream.png -------------------------------------------------------------------------------- /example/expo/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/example/expo/assets/icon.png -------------------------------------------------------------------------------- /example/expo/assets/pizza.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/example/expo/assets/pizza.png -------------------------------------------------------------------------------- /example/expo/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IordanisSap/react-native-circular-layout/0b57cfd7c45922c5671fca2a6ed56c751cb7ad1e/example/expo/assets/splash.png -------------------------------------------------------------------------------- /example/expo/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /example/expo/metro.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { getDefaultConfig } = require('expo/metro-config'); 3 | 4 | const defaultConfig = getDefaultConfig(__dirname); 5 | 6 | defaultConfig.resolver.nodeModulesPaths = [ 7 | path.resolve(__dirname, 'node_modules'), // node_modules inside example/expo 8 | path.resolve(__dirname, '../../node_modules'), // node_modules at the root of react-native-circular-layout 9 | ]; 10 | 11 | defaultConfig.watchFolders = [ 12 | path.resolve(__dirname, '../..'), // Watch the root of react-native-circular-layout 13 | ]; 14 | 15 | defaultConfig.resolver.disableHierarchicalLookup = true; 16 | 17 | module.exports = defaultConfig; -------------------------------------------------------------------------------- /example/expo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-circular-layout-expo-example", 3 | "version": "1.0.0", 4 | "main": "expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web" 10 | }, 11 | "peerDependencies": { 12 | "expo": "^51.0.18", 13 | "react": "18.2.0", 14 | "react-native": "0.74.3", 15 | "react-native-gesture-handler": "~2.16.1", 16 | "react-native-reanimated": "~3.10.1" 17 | }, 18 | "dependencies": { 19 | "react-native-circular-layout": "file:../.." 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "^7.20.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/expo/src/Complex.js: -------------------------------------------------------------------------------- 1 | import { Image, Pressable, SafeAreaView, StyleSheet, Text, View } from 'react-native'; 2 | import CircularView from 'react-native-circular-layout'; 3 | import { GestureHandlerRootView } from 'react-native-gesture-handler'; 4 | import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; 5 | import { useCallback, useRef, useState } from 'react'; 6 | 7 | 8 | const AnimatedPressable = Animated.createAnimatedComponent(Pressable); 9 | export default function Complex() { 10 | 11 | const [index, setIndex] = useState(0); 12 | const prevIndex = useRef(index); 13 | 14 | const radiusX = useSharedValue(150); 15 | const radiusY = useSharedValue(150); 16 | const [scrollEnabled, setScrollEnabled] = useState(true); 17 | const childOpacity = useSharedValue(1); 18 | const isExpanded = useSharedValue(true); 19 | 20 | const indexAnim = useSharedValue(0); 21 | 22 | const onSnapStart = useCallback((index) => { 23 | indexAnim.value = index; 24 | setIndex(index); 25 | }, []); 26 | 27 | const childStyle = useAnimatedStyle(() => { 28 | return { 29 | opacity: childOpacity.value, 30 | } 31 | }); 32 | 33 | const childComponentsImages = [ 34 | { src: require('../assets/burger.png') }, 35 | { src: require('../assets/pizza.png') }, 36 | { src: require('../assets/cocktail.png') }, 37 | { src: require('../assets/ice-cream.png') }, 38 | { src: require('../assets/cup.png') } 39 | ]; 40 | 41 | const colors = ['#D2691E', '#FFA500', '#FF69B4', '#FFB6C1', '#8B4513']; 42 | 43 | 44 | const childComponents = childComponentsImages.map((item, ind) => ( 45 | 53 | )) 54 | 55 | const onCentralPress = () => { 56 | if (isExpanded.value) { 57 | radiusX.value = withTiming(0, { duration: 800, }) 58 | radiusY.value = withTiming(0, { duration: 800, }) 59 | childOpacity.value = withTiming(0, { duration: 500, }) 60 | isExpanded.value = false; 61 | prevIndex.current = index; 62 | indexAnim.value = -1; 63 | setIndex((Math.floor(childComponents.length / 2) + indexAnim.value + 1) % childComponents.length); 64 | 65 | } 66 | else { 67 | radiusX.value = withTiming(getRandomInt(120,180), { duration: 600, }) 68 | radiusY.value = withTiming(getRandomInt(120,180), { duration: 600, }) 69 | childOpacity.value = withTiming(1, { duration: 600, }) 70 | isExpanded.value = true; 71 | indexAnim.value = prevIndex.current; 72 | setIndex(prevIndex.current); 73 | } 74 | } 75 | 76 | 77 | return ( 78 | 79 | 80 | 81 | {/* 82 | */} 83 | 84 | } 89 | gesturesEnabled={scrollEnabled} 90 | index={index} 91 | onSnapStart={(index) => onSnapStart(index)} 92 | onGestureStart={() => indexAnim.value = -1} 93 | onGestureEnd={() => console.log('Gesture End')} 94 | > 95 | {childComponents} 96 | 97 | 98 | {/* */} 99 | 100 | 101 | 102 | ); 103 | } 104 | 105 | const CentralComponent = ({ onPress }) => { 106 | const animatedRotation = useSharedValue(0); 107 | 108 | const animatedStyle = useAnimatedStyle(() => { 109 | return { 110 | transform: [{ rotate: `${animatedRotation.value}deg` }] 111 | } 112 | }); 113 | 114 | const onPressWrapper = () => { 115 | if (animatedRotation.value < 180) animatedRotation.value = withSpring(360, { damping: 10, stiffness: 100 }); 116 | else animatedRotation.value = withSpring(0, { damping: 10, stiffness: 100 }); 117 | onPress(); 118 | } 119 | 120 | return ( 121 | 122 | Press me! 123 | 124 | ) 125 | }; 126 | 127 | 128 | const ChildComponent = ({ style, src, indexAnim, myIndex, color }) => { 129 | const animatedStyle = useAnimatedStyle(() => { 130 | const selected = indexAnim.value === myIndex; 131 | return { 132 | transform: [{ scale: withSpring(selected ? 1.4 : 1, { damping: 10, stiffness: 100 }) }], 133 | shadowColor: 'gray', 134 | style: { 135 | shadowOffset: { 136 | width: 0, 137 | height: 2, 138 | }, 139 | }, 140 | shadowOpacity: withTiming(selected ? 0.8 : 0, { duration: 300 }), 141 | shadowRadius: 5, 142 | elevation: 2, 143 | }; 144 | }); 145 | 146 | return ( 147 | 148 | 149 | 150 | ); 151 | }; 152 | 153 | const styles = StyleSheet.create({ 154 | container: { 155 | flex: 1, 156 | backgroundColor: '#fff', 157 | alignItems: 'center', 158 | justifyContent: 'center', 159 | }, 160 | text: { 161 | color: 'white', 162 | fontSize: 15, 163 | }, 164 | central: { 165 | width: 100, 166 | height: 100, 167 | justifyContent: 'center', 168 | alignItems: 'center', 169 | backgroundColor: '#A17ADD', 170 | borderRadius: 50, 171 | }, 172 | child: { 173 | width: 90, 174 | height: 90, 175 | justifyContent: 'center', 176 | alignItems: 'center', 177 | }, 178 | childBig: { 179 | width: 130, 180 | height: 130, 181 | }, 182 | }); 183 | 184 | 185 | function getRandomInt(min, max) { 186 | const minCeiled = Math.ceil(min); 187 | const maxFloored = Math.floor(max); 188 | return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); // The maximum is exclusive and the minimum is inclusive 189 | } 190 | -------------------------------------------------------------------------------- /example/expo/src/Minimal.js: -------------------------------------------------------------------------------- 1 | import { SafeAreaView, View } from 'react-native'; 2 | import CircularView from 'react-native-circular-layout'; 3 | import { GestureHandlerRootView } from 'react-native-gesture-handler'; 4 | 5 | 6 | export default function Minimal() { 7 | 8 | const colors = ['#D2691E', '#FFA500', '#FF69B4', '#FFB6C1', '#8B4513']; 9 | return ( 10 | 11 | 12 | 13 | 18 | {colors.map((color, index) => ( 19 | 20 | ))} 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /example/expo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } 5 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import CircularView from "./src/CircularLayout"; 2 | import { SnapAngle } from "./src/constants"; 3 | 4 | export { SnapAngle }; 5 | export default CircularView ; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-circular-layout", 3 | "version": "1.0.6", 4 | "main": "index.ts", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "dependencies": { 9 | "react": "18.2.0", 10 | "react-native": "0.74.3", 11 | "react-native-gesture-handler": "~2.16.1", 12 | "react-native-reanimated": "~3.10.1" 13 | }, 14 | "devDependencies": { 15 | "@types/react": "~18.2.45", 16 | "typescript": "^5.1.3" 17 | }, 18 | "author": "Iordanis Sapidis", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/IordanisSap/react-native-circular-layout" 22 | }, 23 | "license": "MIT" 24 | } 25 | -------------------------------------------------------------------------------- /src/CircularLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo } from 'react'; 2 | import { View, StyleSheet, LayoutChangeEvent } from 'react-native'; 3 | import Animated, { useSharedValue, useAnimatedStyle, withDecay, SharedValue, withTiming, runOnJS } from 'react-native-reanimated'; 4 | import { Gesture, GestureDetector } from 'react-native-gesture-handler'; 5 | import { CircleViewProps } from './types'; 6 | import { SnapAngle } from './constants'; 7 | 8 | const defaultAnimationConfig = { 9 | deceleration: 0.99975, 10 | } 11 | 12 | 13 | const CircularView = (props: CircleViewProps) => { 14 | const { radiusX, radiusY, centralComponent = null, rotateCentralComponent = false, 15 | snappingEnabled = true, index = 0, onSnap, onSnapStart, snapAngle = SnapAngle.TOP, snapDuration = 600, 16 | gesturesEnabled: scrollEnabled = true, animationConfig = defaultAnimationConfig, 17 | childContainerStyle = null, onGestureStart, onGestureEnd } = props; 18 | 19 | const angle = useSharedValue(snapAngle); 20 | const initialTouchAngle = useSharedValue(0); 21 | const centerX = useSharedValue(0); 22 | const centerY = useSharedValue(0); 23 | 24 | const numberOfChildren = React.Children.count(props.children); 25 | 26 | const snapPoints = React.useMemo(() => { 27 | const points = []; 28 | for (let i = 0; i < numberOfChildren; i++) { 29 | points.push((2 * Math.PI * i) / numberOfChildren + snapAngle); 30 | } 31 | return points; 32 | }, [numberOfChildren]); 33 | 34 | 35 | 36 | const createPanGesture = (theta: number, sizeX: SharedValue, sizeY: SharedValue) => { 37 | return Gesture.Pan() 38 | .onStart((e) => { 39 | onGestureStart && runOnJS(onGestureStart)(); 40 | 41 | const radiusXValue = typeof radiusX === 'number' ? radiusX : radiusX.value; 42 | const radiusYValue = typeof radiusY === 'number' ? radiusY : radiusY.value; 43 | const elementX = centerX.value + radiusXValue * Math.cos(theta + angle.value) - sizeX.value / 2; 44 | const elementY = centerY.value + radiusYValue * Math.sin(theta + angle.value) - sizeY.value / 2; 45 | 46 | const touchX = elementX + e.x; 47 | const touchY = elementY + e.y; 48 | 49 | initialTouchAngle.value = Math.atan2((touchY - centerY.value)*radiusXValue, (touchX - centerX.value)*radiusYValue) - angle.value; 50 | }) 51 | .onUpdate((e) => { 52 | if (!scrollEnabled) return; 53 | 54 | const radiusXValue = typeof radiusX === 'number' ? radiusX : radiusX.value; 55 | const radiusYValue = typeof radiusY === 'number' ? radiusY : radiusY.value; 56 | const elementX = centerX.value + radiusXValue * Math.cos(theta + angle.value) - sizeX.value / 2; 57 | const elementY = centerY.value + radiusYValue * Math.sin(theta + angle.value) - sizeY.value / 2; 58 | 59 | const touchX = elementX + e.x; 60 | const touchY = elementY + e.y; 61 | 62 | const currentTouchAngle = Math.atan2((touchY - centerY.value)*radiusXValue, (touchX - centerX.value)*radiusYValue); 63 | 64 | angle.value = currentTouchAngle - initialTouchAngle.value; 65 | }) 66 | .onEnd((e) => { 67 | onGestureEnd && runOnJS(onGestureEnd)(); 68 | if (!scrollEnabled) return; 69 | const velocityX = e.velocityX; 70 | const velocityY = e.velocityY; 71 | const radiusXValue = typeof radiusX === 'number' ? radiusX : radiusX.value; 72 | const radiusYValue = typeof radiusY === 'number' ? radiusY : radiusY.value; 73 | const elementX = centerX.value + radiusXValue * Math.cos(theta + angle.value) - sizeX.value / 2; 74 | const elementY = centerY.value + radiusYValue * Math.sin(theta + angle.value) - sizeY.value / 2; 75 | 76 | const touchX = elementX + e.x - centerX.value; 77 | const touchY = elementY + e.y - centerY.value; 78 | 79 | 80 | let direction = Math.abs(velocityX) > Math.abs(velocityY) ? Math.sign(velocityX) : Math.sign(velocityY); 81 | if (Math.abs(velocityX) > Math.abs(velocityY) && touchY > 0) { 82 | direction = -Math.sign(velocityX); 83 | } else if (Math.abs(velocityX) < Math.abs(velocityY) && touchX < 0) { 84 | direction = -Math.sign(velocityY); 85 | } 86 | 87 | let velocity = (Math.abs(velocityX) + Math.abs(velocityY)) / 200; 88 | 89 | if (!snappingEnabled) { 90 | angle.value = withDecay({ velocity: velocity * direction, ...animationConfig }); 91 | } else { 92 | angle.value = withDecay({ velocity: velocity * direction, ...animationConfig }, () => { 93 | const distances = snapPoints.map(point => { 94 | const distanceToFullRotation = (angle.value - point) % (2 * Math.PI); 95 | return Math.abs(distanceToFullRotation) > Math.PI 96 | ? 2 * Math.PI - Math.abs(distanceToFullRotation) 97 | : Math.abs(distanceToFullRotation); 98 | }); 99 | 100 | const minDistance = Math.min(...distances); 101 | const closestSnapIndex = distances.indexOf(minDistance); 102 | 103 | // Calculate the actual snap point 104 | const closestSnapPoint = snapPoints[closestSnapIndex]; 105 | const actualSnapPoint = closestSnapPoint + Math.round((angle.value - closestSnapPoint) / (2 * Math.PI)) * 2 * Math.PI; 106 | 107 | const returnedSnapIndex = closestSnapIndex ? snapPoints.length - closestSnapIndex : 0; //This is because the elements are placed clockwise but rotating clockwise reduces the angle 108 | 109 | onSnapStart && runOnJS(onSnapStart)(returnedSnapIndex); 110 | 111 | angle.value = withTiming(actualSnapPoint, { duration: snapDuration }, () => { 112 | if (onSnap) { 113 | runOnJS(onSnap)(returnedSnapIndex); 114 | } 115 | }); 116 | }); 117 | } 118 | }); 119 | } 120 | 121 | 122 | const createStyle = (theta: number, sizeX: SharedValue, sizeY: SharedValue) => { 123 | const animatedStyle = useAnimatedStyle(() => { 124 | const radiusXValue = typeof radiusX === 'number' ? radiusX : radiusX.value; 125 | const radiusYValue = typeof radiusY === 'number' ? radiusY : radiusY.value; 126 | 127 | const x = centerX.value + radiusXValue * Math.cos(theta + angle.value) - sizeX.value / 2; 128 | const y = centerY.value + radiusYValue * Math.sin(theta + angle.value) - sizeY.value / 2; 129 | 130 | return { 131 | position: 'absolute', 132 | left: x, 133 | top: y, 134 | }; 135 | }); 136 | return [animatedStyle, childContainerStyle]; 137 | } 138 | 139 | const rotatedCentralStyle = useAnimatedStyle(() => { 140 | return { 141 | transform: [{ rotate: angle.value + 'rad' }], 142 | }; 143 | }); 144 | 145 | const thetas = useMemo(() => { 146 | const thetas = []; 147 | for (let i = 0; i < numberOfChildren; i++) { 148 | thetas.push((2 * Math.PI * i) / numberOfChildren); 149 | } 150 | return thetas; 151 | }, [numberOfChildren]); 152 | 153 | 154 | useEffect(() => { 155 | if (index === -1) return; 156 | const invertedIndex = index ? snapPoints.length - index : 0; 157 | const snapPoint = snapPoints[invertedIndex] + Math.round((angle.value - snapPoints[invertedIndex]) / (2 * Math.PI)) * 2 * Math.PI; 158 | 159 | angle.value = withTiming(snapPoint, { duration: snapDuration }, () => { 160 | if (onSnap) { 161 | runOnJS(onSnap)(index); 162 | } 163 | }); 164 | 165 | }, [index]); 166 | 167 | return ( 168 | { 170 | const { width, height } = event.nativeEvent.layout; 171 | centerX.value = width / 2; 172 | centerY.value = height / 2; 173 | }} 174 | > 175 | {React.Children.map(props.children, (child, ind) => { 176 | const theta = thetas[ind]; 177 | return ( 178 | 179 | ); 180 | })} 181 | 182 | {centralComponent} 183 | 184 | 185 | ); 186 | }; 187 | 188 | 189 | 190 | 191 | const Item = ({ theta, index, createStyle, createPanGesture, child }: 192 | { theta: number, index: number, createStyle: (theta: number, sizeX: SharedValue, sizeY: SharedValue) => any, createPanGesture: (theta: number, sizeX: SharedValue, sizeY: SharedValue) => any, child: React.ReactNode }) => { 193 | 194 | const sizeX = useSharedValue(0); 195 | const sizeY = useSharedValue(0); 196 | 197 | const panGesture = createPanGesture(theta, sizeX, sizeY); 198 | const style = createStyle(theta, sizeX, sizeY); 199 | 200 | 201 | return ( 202 | 203 | { 206 | const { width, height } = event.nativeEvent.layout; 207 | sizeX.value = width; 208 | sizeY.value = height; 209 | }} 210 | > 211 | {child} 212 | 213 | 214 | ); 215 | } 216 | 217 | const styles = StyleSheet.create({ 218 | container: { 219 | flex: 1, 220 | justifyContent: 'center', 221 | alignItems: 'center', 222 | }, 223 | }); 224 | 225 | export default CircularView; 226 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The angle at which the view should snap to the nearest child 3 | */ 4 | 5 | export enum SnapAngle { 6 | TOP = -Math.PI / 2, 7 | BOTTOM = Math.PI / 2, 8 | LEFT = Math.PI, 9 | RIGHT = 0, 10 | } -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SharedValue } from 'react-native-reanimated'; 3 | import { DecayConfig } from 'react-native-reanimated/lib/typescript/reanimated2/animation/decay/utils'; 4 | 5 | 6 | 7 | export interface CircleViewProps { 8 | 9 | /** 10 | * The children to be displayed in the circular layout 11 | */ 12 | 13 | children: React.ReactNode; 14 | 15 | /** 16 | * The radius of the ellipse in the x direction. Can be a number or a shared value 17 | */ 18 | 19 | radiusX: number | SharedValue; 20 | 21 | /** 22 | * The radius of the ellipse in the y direction. Can be a number or a shared value 23 | */ 24 | 25 | radiusY: number | SharedValue; 26 | 27 | /** 28 | * The central component to be displayed in the center of the circle/ellipse 29 | */ 30 | 31 | centralComponent?: React.ReactNode; 32 | 33 | 34 | /** 35 | * The index of the child that is currently at currently snapped to the top. 36 | * 37 | * Default: 0 38 | */ 39 | 40 | index?: number; 41 | 42 | /** 43 | * Whether the view should snap to the nearest child 44 | * 45 | * Default: false 46 | */ 47 | 48 | snappingEnabled?: boolean; 49 | 50 | /** 51 | * Called when the view snaps to a child. Called when the animation ends. 52 | */ 53 | 54 | onSnap?: (index: number) => void; 55 | 56 | 57 | /** 58 | * Called when the snapping animation starts 59 | */ 60 | 61 | onSnapStart?: (index: number) => void; 62 | /** 63 | * The angle at which the view should snap to the nearest child 64 | * 65 | * Default: SnapAngle.Top 66 | */ 67 | 68 | snapAngle?: SnapAngle | number; 69 | /** 70 | * Whether the user can pan the view / rotate using touch 71 | * 72 | * Default: true 73 | */ 74 | 75 | gesturesEnabled?: boolean; 76 | 77 | 78 | /** 79 | * Called when the user starts a gesture 80 | */ 81 | 82 | onGestureStart?: () => void; 83 | 84 | /** 85 | * Called when the user ends a gesture 86 | */ 87 | 88 | onGestureEnd?: () => void; 89 | 90 | /** 91 | * Whether the central component should rotate with the rest of the components 92 | * 93 | * Default: false 94 | */ 95 | 96 | rotateCentralComponent?: boolean; 97 | 98 | /** 99 | * The style of the container for the children 100 | */ 101 | 102 | childContainerStyle?: any; 103 | 104 | /** 105 | * The duration of the snapping animation in ms 106 | */ 107 | 108 | snapDuration?: number; 109 | 110 | /** 111 | * The configuration for the decay animation 112 | * 113 | * NOTE: deceleration values below 0.9 will cause the animation to stop almost immediately 114 | */ 115 | 116 | animationConfig?: DecayConfig; 117 | 118 | } 119 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "commonjs", 5 | "target": "ES5", 6 | "lib": [ 7 | "ES6", 8 | "DOM" 9 | ], 10 | "sourceMap": true, 11 | "declaration": true, 12 | "jsx": "react", 13 | "moduleResolution": "node", 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "allowSyntheticDefaultImports": true, 17 | "strict": true, 18 | "forceConsistentCasingInFileNames": true 19 | }, 20 | "include": [ 21 | "src/**/*", 22 | "index.tsx" 23 | ], 24 | "exclude": [ 25 | "node_modules", 26 | "dist", 27 | "**/*.test.ts", 28 | "**/*.test.tsx" 29 | ] 30 | } --------------------------------------------------------------------------------