├── .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 | [](https://www.npmjs.com/package/react-native-circular-layout)
5 | 
6 | [](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 |
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 |
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 | }
--------------------------------------------------------------------------------