├── .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 | ![Example](./example/example.gif) 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 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 140 | 146 | 147 | 148 | 149 | 150 | {steps && steps.map((step, index) => ( 151 | 152 | goToPercent(step.p)} 157 | /> 158 | goToPercent(step.p)} 163 | /> 164 | 165 | ))} 166 | 167 | 173 | 174 | {children && ( 175 | 176 | {children} 177 | 178 | )} 179 | 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 | --------------------------------------------------------------------------------