├── .npmignore
├── example
├── example.gif
├── assets
│ ├── icon.png
│ └── splash.png
├── babel.config.js
├── .expo-shared
│ └── assets.json
├── .gitignore
├── app.json
├── package.json
└── App.js
├── package.json
├── LICENCE
├── readme.md
└── index.js
/.npmignore:
--------------------------------------------------------------------------------
1 | example/
--------------------------------------------------------------------------------
/example/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianguyonnet/react-native-circular-picker/HEAD/example/example.gif
--------------------------------------------------------------------------------
/example/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianguyonnet/react-native-circular-picker/HEAD/example/assets/icon.png
--------------------------------------------------------------------------------
/example/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/florianguyonnet/react-native-circular-picker/HEAD/example/assets/splash.png
--------------------------------------------------------------------------------
/example/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/example/.expo-shared/assets.json:
--------------------------------------------------------------------------------
1 | {
2 | "f9155ac790fd02fadcdeca367b02581c04a353aa6d5aa84409a59f6804c87acd": true,
3 | "89ed26367cdb9b771858e026f2eb95bfdb90e5ae943e716575327ec325f39c44": true
4 | }
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/**/*
2 | .expo/*
3 | npm-debug.*
4 | *.jks
5 | *.p8
6 | *.p12
7 | *.key
8 | *.mobileprovision
9 | *.orig.*
10 | web-build/
11 | web-report/
12 |
13 | # macOS
14 | .DS_Store
15 |
--------------------------------------------------------------------------------
/example/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "example",
4 | "slug": "example",
5 | "privacy": "public",
6 | "platforms": [
7 | "ios",
8 | "android",
9 | "web"
10 | ],
11 | "version": "1.0.0",
12 | "orientation": "portrait",
13 | "icon": "./assets/icon.png",
14 | "splash": {
15 | "image": "./assets/splash.png",
16 | "resizeMode": "contain",
17 | "backgroundColor": "#ffffff"
18 | },
19 | "updates": {
20 | "fallbackToCacheTimeout": 0
21 | },
22 | "assetBundlePatterns": [
23 | "**/*"
24 | ],
25 | "ios": {
26 | "supportsTablet": true
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": "node_modules/expo/AppEntry.js",
3 | "scripts": {
4 | "start": "expo start",
5 | "android": "expo start --android",
6 | "ios": "expo start --ios",
7 | "web": "expo start --web",
8 | "eject": "expo eject"
9 | },
10 | "dependencies": {
11 | "@babel/runtime": "^7.8.4",
12 | "expo": "^40.0.0",
13 | "react": "16.13.1",
14 | "react-dom": "16.13.1",
15 | "react-native": "https://github.com/expo/react-native/archive/sdk-40.0.0.tar.gz",
16 | "react-native-circular-picker": "file:../",
17 | "react-native-svg": "12.1.0",
18 | "react-native-web": "~0.13.12"
19 | },
20 | "devDependencies": {
21 | "@babel/core": "~7.9.0",
22 | "babel-preset-expo": "8.3.0"
23 | },
24 | "private": true
25 | }
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-native-circular-picker",
3 | "version": "1.0.6",
4 | "description": "Apple Card circular picker component in react native.",
5 | "main": "index.js",
6 | "directories": {
7 | "example": "example"
8 | },
9 | "keywords": [
10 | "react",
11 | "react-native",
12 | "react-component",
13 | "progress",
14 | "chart",
15 | "react-svg",
16 | "picker",
17 | "apple cards",
18 | "circular picker"
19 | ],
20 | "scripts": {
21 | "test": "echo \"Error: no test specified\" && exit 1"
22 | },
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://github.com/florianguyonnet/react-native-circular-picker.git"
26 | },
27 | "author": "Florian Guyonnet",
28 | "license": "MIT",
29 | "bugs": {
30 | "url": "https://github.com/florianguyonnet/react-native-circular-picker/issues"
31 | },
32 | "homepage": "https://github.com/florianguyonnet/react-native-circular-picker#readme"
33 | }
34 |
--------------------------------------------------------------------------------
/LICENCE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) [2015-2016] [Horcrux]
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.
--------------------------------------------------------------------------------
/example/App.js:
--------------------------------------------------------------------------------
1 |
2 | import React, { useState } from 'react';
3 | import { View, Text, Dimensions } from 'react-native';
4 | import CircularPicker from 'react-native-circular-picker';
5 |
6 | export default () => {
7 | const [price, setPrice] = useState(0);
8 | const handleChange = (v) => setPrice((v * 10).toFixed(0));
9 |
10 | return (
11 |
12 |
23 | <>
24 | {price} $
25 | Available balance 1000 $
26 | >
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | ## react-native-circular-picker (WIP)
2 |
3 | Apple Card circular picker component in react native.
4 |
5 | 
6 |
7 | ## Installation
8 |
9 | ##### 1. Install [react-native-svg](https://github.com/react-native-community/react-native-svg)
10 |
11 | ``
12 | expo install react-native-svg
13 | ``
14 |
15 | ##### 2. Install react-native-circular-picker
16 |
17 | ``
18 | yarn add react-native-circular-picker
19 | ``
20 |
21 | ## Usage
22 |
23 | ```js
24 | import { useState } from 'react';
25 | import CircularPicker from 'react-native-circular-picker';
26 |
27 | export default () => {
28 | const [price, setPrice] = useState(0);
29 | const handleChange = (v) => setPrice((v * 20).toFixed(0));
30 |
31 | return (
32 |
43 | <>
44 | {price} $
45 | Available balance 2000 $
46 | >
47 |
48 | );
49 | }
50 | ```
51 |
52 | ## Props
53 |
54 | | Name | Default | type | description |
55 | | ----------------------|--------------------------------------------------------|------------|-------------------|
56 | | size **(required)** | - | number | size of the component
57 | | strokeWidth | 45 | number |
58 | | defaultPos | 0 | number |
59 | | steps | [] | [number] |
60 | | gradients | ``{ 0: ['rgb(255, 204, 0)', 'rgb(255, 214, 10)'] }`` | object |
61 | | backgroundColor | ``'rgb(231, 231, 231)'`` | string |
62 | | stepColor | ``'rgba(0, 0, 0, 0.2)'`` | string |
63 | | borderColor | ``'rgb(255, 255, 255)'`` | string |
64 | | onChange | (val) => undefined | function | value in percent
65 |
66 | #### Steps
67 |
68 | You can defines clickable steps on the circle.
69 | steps are defined by an array of position (0 - 100).
70 |
71 | #### Gradients
72 |
73 | You can define gradients according the position of the cursor on the circle.
74 | Gradients are defined by an object, the key is the starting percentage and the value is an array of colors.
75 |
76 | ```js
77 | {
78 | 0: ['rgb(255, 97, 99)', 'rgb(247, 129, 119)'],
79 | 15: ['rgb(255, 204, 0)', 'rgb(255, 214, 10)'],
80 | 40: ['rgb(52, 199, 89)', 'rgb(48, 209, 88)'],
81 | 70: ['rgb(0, 122, 255)', 'rgb(10, 132, 255)'],
82 | }
83 | ```
84 |
85 | ## @TODO
86 | - Animation with React Animated when you click on steps
87 | - Improve end bounding area
88 |
89 | ## Author
90 |
91 | [Florian Guyonnet](https://github.com/florianguyonnet)
92 |
93 | ## License
94 |
95 | MIT
96 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState, useEffect } from 'react';
2 | import { PanResponder, View } from 'react-native';
3 | import PropTypes from 'prop-types';
4 | import Svg, {
5 | Circle, G, LinearGradient, Path, Defs, Stop,
6 | } from 'react-native-svg';
7 |
8 | const { PI, cos, sin, atan2 } = Math;
9 |
10 | const calculateAngle = (pos, radius) => {
11 | const startAngle = ((2 * PI) - (PI * -0.5));
12 | const endAngle = (PI + (PI * pos));
13 |
14 | const x1 = -radius * cos(startAngle);
15 | const y1 = -radius * sin(startAngle);
16 |
17 | const x2 = -radius * cos(endAngle);
18 | const y2 = -radius * sin(endAngle);
19 |
20 | return { x1, y1, x2, y2 };
21 | };
22 |
23 | const calculateRealPos = (x, y, radius, strokeWidth) => ({
24 | endX: x + radius + strokeWidth / 2,
25 | endY: y + radius + strokeWidth / 2,
26 | });
27 |
28 | const calculateMovement = (x, y, radius, strokeWidth) => {
29 | const cx = ((x + strokeWidth) / radius) - PI / 2;
30 | const cy = -(((y + strokeWidth) / radius) - PI / 2);
31 |
32 | let pos = -atan2(cy, cx) / PI;
33 | if (pos < -0.5) {
34 | pos += 2;
35 | }
36 |
37 | return pos;
38 | };
39 |
40 | const percentToPos = (percent) => (2 / 100 * percent) - 0.5;
41 | const posToPercent = (pos) => 100 * (pos + 0.5) / 2;
42 |
43 | const selectGradient = (gradients, pos) => {
44 | const current = posToPercent(pos);
45 | let selected = 0;
46 |
47 | for (const [key] of Object.entries(gradients)) {
48 | if (key > selected && key < current) {
49 | selected = key;
50 | }
51 | }
52 |
53 | return gradients[selected];
54 | };
55 |
56 | const CircularPicker = ({
57 | size,
58 | strokeWidth,
59 | defaultPos,
60 | steps,
61 | gradients,
62 | backgroundColor,
63 | stepColor,
64 | borderColor,
65 | children,
66 | onChange,
67 | }) => {
68 | const [pos, setPos] = useState(percentToPos(defaultPos));
69 | const circle = useRef(null);
70 |
71 | const padding = 8;
72 | const radius = (size - strokeWidth) / 2 - padding;
73 | const center = (radius + strokeWidth / 2);
74 |
75 | const gradient = selectGradient(gradients, pos);
76 |
77 | useEffect(()=>{
78 | setPos(percentToPos(defaultPos));
79 | }, [defaultPos]);
80 |
81 | if (steps) {
82 | steps = steps.map((p) => {
83 | const pos = percentToPos(p);
84 | const { x2, y2 } = calculateAngle(pos, radius);
85 | const { endX: x, endY: y } = calculateRealPos(x2, y2, radius, strokeWidth);
86 | return { x, y, p };
87 | });
88 | }
89 |
90 | const { x1, y1, x2, y2 } = calculateAngle(pos, radius);
91 | const { endX, endY } = calculateRealPos(x2, y2, radius, strokeWidth);
92 |
93 | const goToPercent = (p) => {
94 | const newPos = percentToPos(p);
95 | setPos(newPos);
96 | onChange(posToPercent(newPos));
97 | }
98 |
99 | const pan = PanResponder.create({
100 | onMoveShouldSetPanResponder: () => true,
101 | onMoveShouldSetPanResponderCapture: () => true,
102 | onPanResponderMove: (_, { moveX, moveY }) => {
103 | circle.current.measure((x, y, width, height, px, py) => {
104 | const newPos = calculateMovement(moveX - px, moveY - py, radius, strokeWidth);
105 | /**
106 | * @TODO
107 | */
108 | if ((newPos < -0.3 && pos > 1.3)
109 | || (newPos > 1.3 && pos < -0.3)) {
110 | return;
111 | }
112 | setPos(newPos);
113 | onChange(posToPercent(newPos));
114 | });
115 | }
116 | });
117 |
118 | const d = `
119 | M ${x2.toFixed(3)} ${y2.toFixed(3)}
120 | A ${radius} ${radius}
121 | ${(pos < 0.5) ? '1' : '0'} ${(pos > 0.5) ? '1' : '0'} 0
122 | ${x1.toFixed(3)} ${y1.toFixed(3)}
123 | `;
124 |
125 | return (
126 |
180 | );
181 | }
182 |
183 | CircularPicker.propTypes = {
184 | size: PropTypes.number.isRequired,
185 | strokeWidth: PropTypes.number,
186 | defaultPos: PropTypes.number,
187 | steps: PropTypes.arrayOf(PropTypes.number),
188 | gradients: PropTypes.objectOf(
189 | PropTypes.arrayOf(PropTypes.string)
190 | ),
191 | backgroundColor: PropTypes.string,
192 | stepColor: PropTypes.string,
193 | borderColor: PropTypes.string,
194 | onChange: PropTypes.func,
195 | children: PropTypes.any,
196 | };
197 |
198 | CircularPicker.defaultProps = {
199 | strokeWidth: 45,
200 | defaultPos: 0,
201 | steps: [],
202 | gradients: {
203 | 0: ['rgb(255, 204, 0)', 'rgb(255, 214, 10)'],
204 | },
205 | backgroundColor: 'rgb(231, 231, 231)',
206 | stepColor: 'rgba(0, 0, 0, 0.2)',
207 | borderColor: 'rgb(255, 255, 255)',
208 | onChange: () => undefined,
209 | children: null,
210 | };
211 |
212 | export default CircularPicker;
213 |
--------------------------------------------------------------------------------