├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── RELEASE_NOTES.md ├── demo.gif ├── package-lock.json ├── package.json ├── patches └── react-native-gesture-handler+1.10.3.patch ├── src ├── HueSaturationValuePicker.tsx ├── HueSaturationWheel.tsx ├── Slider.tsx ├── ValueSlider.tsx ├── Wheel.tsx ├── colorHSV.ts ├── defaultStyle.ts ├── index.ts ├── size.ts └── tsconfig.json └── tslint.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:15.9.0 6 | working_directory: ~/repo 7 | steps: 8 | - checkout 9 | 10 | - run: npm i && npm run lint && npm run build 11 | 12 | - persist_to_workspace: 13 | root: . 14 | paths: 15 | - dist/* 16 | 17 | publish: 18 | docker: 19 | - image: circleci/node:15.9.0 20 | steps: 21 | - checkout 22 | - attach_workspace: 23 | at: . 24 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 25 | - run: npm publish 26 | 27 | workflows: 28 | version: 2 29 | package: 30 | jobs: 31 | - build: 32 | filters: 33 | tags: 34 | only: /.*/ 35 | - publish: 36 | context: iyegoroff 37 | requires: 38 | - build 39 | filters: 40 | tags: 41 | only: /^v.*/ 42 | branches: 43 | ignore: /.*/ 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | dist 76 | 77 | .ionide 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 iyegoroff 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-reanimated-color-picker 2 | [![npm version](https://badge.fury.io/js/react-native-reanimated-color-picker.svg?t=1495378566925)](https://badge.fury.io/js/react-native-reanimated-color-picker) 3 | [![CircleCI](https://circleci.com/gh/iyegoroff/react-native-reanimated-color-picker.svg?style=svg)](https://circleci.com/gh/iyegoroff/react-native-reanimated-color-picker) 4 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](https://github.com/standard/standard) 5 | [![Dependency Status](https://david-dm.org/iyegoroff/react-native-reanimated-color-picker.svg?t=1495378566925)](https://david-dm.org/iyegoroff/react-native-reanimated-color-picker) 6 | [![devDependencies Status](https://david-dm.org/iyegoroff/react-native-reanimated-color-picker/dev-status.svg)](https://david-dm.org/iyegoroff/react-native-reanimated-color-picker?type=dev) 7 | [![typings included](https://img.shields.io/badge/typings-included-brightgreen.svg?t=1495378566925)](package.json) 8 | [![npm](https://img.shields.io/npm/l/express.svg?t=1495378566925)](https://www.npmjs.com/package/react-native-reanimated-color-picker) 9 | 10 | Natively animated HSV color picker for iOS & Android 11 | 12 | ## Installation 13 | 14 | - Install peer dependencies: [react-native-image-filter-kit](https://github.com/iyegoroff/react-native-image-filter-kit#react-native-image-filter-kit), [react-native-reanimated](https://github.com/kmagiera/react-native-reanimated#getting-started), [react-native-gesture-handler](https://kmagiera.github.io/react-native-gesture-handler/docs/getting-started.html#installation) 15 | 16 | - `$ npm install react-native-reanimated-color-picker --save` 17 | 18 | ## Demo 19 | 20 | 21 | 22 | ## Example 23 | 24 | ```jsx 25 | import * as React from 'react' 26 | import { HueSaturationValuePicker } from 'react-native-reanimated-color-picker' 27 | 28 | const wheelStyle = { width: '100%' } 29 | const sliderStyle = { height: 50, width: '100%' } 30 | 31 | const colorChanged = ({ h, s, v }) => { 32 | console.warn(h, s, v) 33 | } 34 | 35 | const picker = ( 36 | 41 | ) 42 | ``` 43 | 44 | ## Description 45 | 46 | There are three components: 47 | - `HueSaturationWheel` - a wheel for selecting hue and saturation with constant value = 1 48 | - `ValueSlider` - a slider for selecting value 49 | - `HueSaturationValuePicker` - a composition of two first components 50 | 51 | The library doesn't provide any color conversion functions, so you have to use a third-party module for color conversion 52 | 53 | ## Reference 54 | 55 | ### HueSaturationWheel props 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 83 | 84 | 85 | 86 | 87 | 88 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 |
proptypedefaultdesc
styleViewStyle-required
snapToCenternumber-Thumb will snap to center of the wheel when the distance is less than snapToCenter
onColorChangeComplete
(color: {
 79 |   h: number,
 80 |   s: number,
 81 |   v: number
 82 | }) => void
-Called when touch ended
onColorChange
(color: {
 89 |   h: number,
 90 |   s: number,
 91 |   v: number
 92 | }) => void
-Called when touch moved
valueAnimated.Node<number>new Animated.Value(1)value node from ValueSlider
valueGestureStateAnimated.Node<number>new Animated.Value(State.END)ValueSlider gesture state
thumbRadiusnumber50
initialHuenumber0hue in degrees
initialSaturationnumber0[0..1]
127 | 128 | ### ValueSlider props 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 160 | 161 | 162 | 163 |
proptypedefaultdesc
styleViewStyle-required
thumbWidthnumber50
initialValuenumber1[0..1]
onValueInit
(value: Animated.Node<number>,
159 |  gestureState: Animated.Node<number>) => void
-used to wire ValudeSlider with HueSaturationWheel
164 | 165 | 166 | ### HueSaturationValuePicker props 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 200 | 201 | 202 | 203 | 204 | 205 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 |
proptypedefaultdesc
wheelStyleViewStyle-required
sliderStyleViewStyle-required
snapToCenternumber-Thumb will snap to center of the wheel when the distance is less than snapToCenter
onColorChangeComplete
(color: {
196 |   h: number,
197 |   s: number,
198 |   v: number
199 | }) => void
-Called when touch ended
onColorChange
(color: {
206 |   h: number,
207 |   s: number,
208 |   v: number
209 | }) => void
-Called when touch moved
thumbSizenumber50thumbRadius and thumbWidth
initialHuenumber0hue in degrees
initialSaturationnumber0[0..1]
initialValuenumber1[0..1]
238 | 239 | ## Credits 240 | 241 | - `colorHSV` function was taken from `react-native-reanimated` [example](https://github.com/kmagiera/react-native-reanimated/blob/master/Example/colors/index.js) by @kmagiera 242 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | [release notes](https://github.com/iyegoroff/react-native-reanimated-color-picker/releases) 2 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iyegoroff/react-native-reanimated-color-picker/424bb160f3e02f02a11a598e78f9aa607ed48e32/demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-reanimated-color-picker", 3 | "version": "0.0.11", 4 | "description": "Natively animated HSV color picker for iOS & Android", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "preversion": "npm run build && npm run lint", 9 | "postversion": "git push && git push --tags", 10 | "build": "patch-package && rm -rf dist && mkdir dist && tsc -p src", 11 | "lint": "tslint -p src", 12 | "watch": "npm run build -- --watch" 13 | }, 14 | "keywords": [ 15 | "react-native", 16 | "reanimated", 17 | "color-picker" 18 | ], 19 | "files": [ 20 | "dist", 21 | "src", 22 | "*.md", 23 | "LICENSE" 24 | ], 25 | "author": "iyegoroff ", 26 | "license": "MIT", 27 | "peerDependencies": { 28 | "react": "*", 29 | "react-native": "*", 30 | "react-native-gesture-handler": "*", 31 | "react-native-image-filter-kit": "*", 32 | "react-native-reanimated": "*" 33 | }, 34 | "devDependencies": { 35 | "@types/react": "^17.0.6", 36 | "@types/react-native": "^0.64.5", 37 | "patch-package": "^6.4.7", 38 | "react-native-gesture-handler": "^1.10.3", 39 | "react-native-image-filter-kit": "^0.8.0", 40 | "react-native-reanimated": "^2.1.0", 41 | "tslint": "^6.1.3", 42 | "tslint-config-standard": "^9.0.0", 43 | "tslint-react": "^5.0.0", 44 | "typescript": "^4.2.4" 45 | }, 46 | "bugs": { 47 | "url": "https://github.com/iyegoroff/react-native-reanimated-color-picker/issues" 48 | }, 49 | "homepage": "https://github.com/iyegoroff/react-native-reanimated-color-picker#readme", 50 | "repository": { 51 | "type": "git", 52 | "url": "git+https://github.com/iyegoroff/react-native-reanimated-color-picker.git" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /patches/react-native-gesture-handler+1.10.3.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-native-gesture-handler/lib/typescript/components/touchables/GenericTouchable.d.ts b/node_modules/react-native-gesture-handler/lib/typescript/components/touchables/GenericTouchable.d.ts 2 | index 343288f..6c843e7 100644 3 | --- a/node_modules/react-native-gesture-handler/lib/typescript/components/touchables/GenericTouchable.d.ts 4 | +++ b/node_modules/react-native-gesture-handler/lib/typescript/components/touchables/GenericTouchable.d.ts 5 | @@ -1,4 +1,3 @@ 6 | -/// 7 | import { Component } from 'react'; 8 | import { StyleProp, ViewStyle, TouchableWithoutFeedbackProps } from 'react-native'; 9 | import { GestureEvent, HandlerStateChangeEvent } from '../../handlers/gestureHandlers'; 10 | -------------------------------------------------------------------------------- /src/HueSaturationValuePicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ViewStyle, StyleProp } from 'react-native' 3 | import Animated from 'react-native-reanimated' 4 | import { State as GestureState } from 'react-native-gesture-handler' 5 | import { HSV, HueSaturationWheel } from './HueSaturationWheel' 6 | import { ValueSlider } from './ValueSlider' 7 | 8 | type Props = { 9 | readonly wheelStyle: StyleProp 10 | readonly sliderStyle: StyleProp 11 | readonly snapToCenter?: number 12 | readonly onColorChangeComplete?: (color: HSV) => void 13 | readonly onColorChange?: (color: HSV) => void 14 | readonly thumbSize?: number 15 | readonly initialHue?: number 16 | readonly initialSaturation?: number 17 | readonly initialValue?: number 18 | } 19 | 20 | type State = { 21 | readonly value: Animated.Node 22 | readonly valueGestureState: Animated.Node 23 | } 24 | 25 | export class HueSaturationValuePicker extends React.PureComponent< 26 | Props, 27 | State 28 | > { 29 | state: State = { 30 | value: new Animated.Value(1), 31 | valueGestureState: new Animated.Value(GestureState.UNDETERMINED) 32 | } 33 | 34 | render() { 35 | const { 36 | wheelStyle, 37 | sliderStyle, 38 | thumbSize, 39 | snapToCenter, 40 | initialHue, 41 | initialValue, 42 | initialSaturation, 43 | onColorChange, 44 | onColorChangeComplete 45 | } = this.props 46 | const { value, valueGestureState } = this.state 47 | 48 | return ( 49 | <> 50 | 61 | 67 | 68 | ) 69 | } 70 | 71 | private valueInit = ( 72 | value: Animated.Node, 73 | valueGestureState: Animated.Node 74 | ) => { 75 | this.setState({ value, valueGestureState }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/HueSaturationWheel.tsx: -------------------------------------------------------------------------------- 1 | // tslint:disable:max-file-line-count 2 | import React from 'react' 3 | import { View, ViewStyle, StyleSheet, LayoutChangeEvent, StyleProp } from 'react-native' 4 | import { 5 | State as GestureState, 6 | PanGestureHandlerGestureEvent, 7 | TapGestureHandlerGestureEvent 8 | } from 'react-native-gesture-handler' 9 | import Animated from 'react-native-reanimated' 10 | import { Wheel } from './Wheel' 11 | import { colorHSV } from './colorHSV' 12 | import { defaultStyle } from './defaultStyle' 13 | 14 | const { 15 | and, 16 | neq, 17 | set, 18 | block, 19 | Value, 20 | multiply, 21 | divide, 22 | sqrt, 23 | pow, 24 | sub, 25 | add, 26 | greaterThan, 27 | cond, 28 | event, 29 | interpolateNode, 30 | Extrapolate, 31 | lessThan, 32 | acos, 33 | eq, 34 | lessOrEq 35 | } = Animated 36 | 37 | export type HSV = { 38 | readonly h: number 39 | readonly s: number 40 | readonly v: number 41 | } 42 | 43 | type Props = { 44 | readonly style: StyleProp, 45 | readonly snapToCenter?: number 46 | readonly onColorChangeComplete?: (color: HSV) => void 47 | readonly onColorChange?: (color: HSV) => void 48 | readonly value?: Animated.Node 49 | readonly valueGestureState?: Animated.Node 50 | readonly thumbRadius?: number 51 | readonly initialHue?: number 52 | readonly initialSaturation?: number 53 | } 54 | 55 | type State = { 56 | readonly hue?: Animated.Node 57 | readonly saturation?: Animated.Node 58 | readonly side?: number 59 | readonly posX?: Animated.Node 60 | readonly posY?: Animated.Node 61 | readonly startX?: Animated.Node 62 | readonly startY?: Animated.Node 63 | readonly translateX?: Animated.Node 64 | readonly translateY?: Animated.Node 65 | readonly thumbColor?: Animated.Node 66 | readonly wheelOpacity?: Animated.Node 67 | readonly panGestureEvent?: ( 68 | event: PanGestureHandlerGestureEvent | TapGestureHandlerGestureEvent 69 | ) => void 70 | readonly gestureState?: Animated.Node 71 | readonly codeKey?: number 72 | } 73 | 74 | export class HueSaturationWheel extends React.PureComponent { 75 | 76 | static defaultProps: Partial = { 77 | value: new Value(1), 78 | valueGestureState: new Animated.Value(GestureState.UNDETERMINED), 79 | thumbRadius: 50 80 | } 81 | 82 | constructor(props: Props) { 83 | super(props) 84 | 85 | const { style, snapToCenter, value, thumbRadius, initialHue, initialSaturation } = props 86 | const side = HueSaturationWheel.side(style) 87 | 88 | this.state = side === undefined || thumbRadius === undefined 89 | ? {} 90 | : ( 91 | HueSaturationWheel.state( 92 | side, 93 | thumbRadius, 94 | value!, 95 | 0, 96 | snapToCenter, 97 | initialHue, 98 | initialSaturation 99 | ) 100 | ) 101 | } 102 | 103 | static isGestureStartedInsideCircle( 104 | gestureState: Animated.Node, 105 | startX: Animated.Node, 106 | startY: Animated.Node, 107 | thumbSize: number, 108 | side: number 109 | ) { 110 | const halfThumbSize = thumbSize / 2 111 | const center = HueSaturationWheel.center(side, thumbSize) 112 | const radius = center 113 | 114 | return ( 115 | and( 116 | neq(gestureState, GestureState.UNDETERMINED), 117 | neq(gestureState, GestureState.FAILED), 118 | lessOrEq( 119 | sqrt( 120 | add( 121 | pow(sub(startX, center + halfThumbSize), 2), 122 | pow(sub(startY, center + halfThumbSize), 2) 123 | ) 124 | ), 125 | radius + thumbSize / 1.5 126 | ) 127 | ) 128 | ) 129 | } 130 | 131 | private static center(side: number, thumbSize: number) { 132 | return (side - thumbSize) / 2 133 | } 134 | 135 | private static toCartesian(degree: number, radius: number) { 136 | const rad = degree / 180 * Math.PI 137 | 138 | return { 139 | x: Math.cos(rad) * radius, 140 | y: Math.sin(rad) * radius 141 | } 142 | } 143 | 144 | private static state( 145 | side: number, 146 | thumbRadius: number, 147 | value: Animated.Node, 148 | codeKey: number = 0, 149 | snapToCenter: number = 0, 150 | initialHue: number = 0, 151 | initialSaturation: number = 0 152 | ): Required { 153 | const halfThumbSize = thumbRadius / 2 154 | const center = HueSaturationWheel.center(side, thumbRadius) 155 | const radius = center 156 | const startX = new Value(0) 157 | const startY = new Value(0) 158 | const cartesian = HueSaturationWheel.toCartesian(-initialHue, initialSaturation * radius) 159 | const posX = new Value(center + cartesian.x) 160 | const posY = new Value(center + cartesian.y) 161 | const gestureState = new Value(GestureState.UNDETERMINED) 162 | const distance = sqrt(add(pow(sub(posX, center), 2), pow(sub(posY, center), 2))) 163 | const shouldSnapToCenter = lessThan(distance, snapToCenter) 164 | const dist = cond(shouldSnapToCenter, 0, distance) 165 | const isOutsideWheel = greaterThan(dist, radius) 166 | const isInCenter = eq(dist, 0) 167 | const sinT = cond(isInCenter, 0, divide(sub(posY, center), dist)) 168 | const cosT = cond(isInCenter, 1, divide(sub(posX, center), dist)) 169 | 170 | const translateX: Animated.Node = cond( 171 | shouldSnapToCenter, 172 | center, 173 | cond(isOutsideWheel, add(center, multiply(radius, cosT)), posX) 174 | ) 175 | 176 | const translateY: Animated.Node = cond( 177 | shouldSnapToCenter, 178 | center, 179 | cond(isOutsideWheel, add(center, multiply(radius, sinT)), posY) 180 | ) 181 | 182 | const angle = divide(multiply(acos(cosT), 180), Math.PI) 183 | const hue = cond( 184 | greaterThan(sub(translateY, center), 0), 185 | sub(360, angle), 186 | angle 187 | ) 188 | 189 | const saturation = interpolateNode(dist, { 190 | inputRange: [0, radius], 191 | outputRange: [0, 1], 192 | extrapolate: Extrapolate.CLAMP 193 | }) 194 | 195 | return { 196 | codeKey: codeKey + 1, 197 | wheelOpacity: sub(1, value), 198 | gestureState, 199 | side, 200 | translateX, 201 | translateY, 202 | posX, 203 | posY, 204 | startX, 205 | startY, 206 | hue, 207 | saturation, 208 | // tslint:disable-next-line: no-any 209 | thumbColor: colorHSV(hue, saturation, value) as any, 210 | panGestureEvent: event([{ 211 | nativeEvent: ({ x, y, state }: { x: number, y: number, state: GestureState }) => ( 212 | block([ 213 | set(gestureState, state), 214 | cond( 215 | eq(gestureState, GestureState.BEGAN), 216 | [ 217 | set(startX, x), 218 | set(startY, y) 219 | ] 220 | ), 221 | cond( 222 | HueSaturationWheel.isGestureStartedInsideCircle( 223 | gestureState, 224 | startX, 225 | startY, 226 | thumbRadius, 227 | side 228 | ), 229 | [ 230 | set(posX, sub(x, halfThumbSize)), 231 | set(posY, sub(y, halfThumbSize)) 232 | ] 233 | ) 234 | ]) 235 | ) 236 | }]) 237 | } 238 | } 239 | 240 | private static side(style?: StyleProp): number | undefined { 241 | if (style !== undefined) { 242 | const { width, height } = StyleSheet.flatten(style) 243 | 244 | if (typeof width === 'number' && typeof height === 'number') { 245 | return Math.min(width, height) 246 | } 247 | 248 | if (typeof width === 'number') { 249 | return width 250 | } 251 | 252 | if (typeof height === 'number') { 253 | return height 254 | } 255 | } 256 | 257 | return undefined 258 | } 259 | 260 | componentDidUpdate(prevProps: Props, prevState: State) { 261 | const { style, snapToCenter, value, thumbRadius, initialHue, initialSaturation } = this.props 262 | const { width, height } = StyleSheet.flatten(style) 263 | const { width: prevWidth, height: prevHeight } = StyleSheet.flatten(prevProps.style) 264 | 265 | if (prevHeight !== height || prevWidth !== width) { 266 | this.setState({ side: HueSaturationWheel.side(style) }) 267 | 268 | } else { 269 | const { side = HueSaturationWheel.side(style) } = this.state 270 | 271 | if (( 272 | side !== undefined && thumbRadius !== undefined 273 | ) && ( 274 | side !== prevState.side || 275 | value !== prevProps.value || 276 | snapToCenter !== prevProps.snapToCenter || 277 | thumbRadius !== prevProps.thumbRadius 278 | )) { 279 | this.setState(HueSaturationWheel.state( 280 | side, 281 | thumbRadius, 282 | value!, 283 | prevState.codeKey, 284 | snapToCenter, 285 | initialHue, 286 | initialSaturation 287 | )) 288 | } 289 | } 290 | } 291 | 292 | render() { 293 | const { style, valueGestureState, thumbRadius, value } = this.props 294 | const { 295 | side, 296 | codeKey, 297 | thumbColor, 298 | translateX, 299 | translateY, 300 | wheelOpacity, 301 | panGestureEvent, 302 | gestureState, 303 | startX, 304 | startY, 305 | hue, 306 | saturation 307 | } = this.state 308 | 309 | const containerStyle = side === undefined ? {} : { width: side, height: side } 310 | 311 | return ( 312 | 317 | {( 318 | side !== undefined && 319 | thumbColor !== undefined && 320 | translateX !== undefined && 321 | translateY !== undefined && 322 | wheelOpacity !== undefined && 323 | panGestureEvent !== undefined && 324 | gestureState !== undefined && 325 | codeKey !== undefined && 326 | startX !== undefined && 327 | startY !== undefined && 328 | valueGestureState !== undefined && 329 | thumbRadius !== undefined && 330 | hue !== undefined && 331 | saturation !== undefined && 332 | value !== undefined 333 | ) ? ( 334 | 353 | ) 354 | : false 355 | } 356 | 357 | ) 358 | } 359 | 360 | private colorChangeComplete = ([h, s, v]: readonly number[]) => { 361 | const { onColorChangeComplete = noop } = this.props 362 | 363 | onColorChangeComplete({ h, s, v }) 364 | } 365 | 366 | private colorChange = ([h, s, v]: readonly number[]) => { 367 | const { onColorChange = noop } = this.props 368 | 369 | onColorChange({ h, s, v }) 370 | } 371 | 372 | private layout = ({ nativeEvent: { layout: { width, height } } }: LayoutChangeEvent) => { 373 | this.setState({ side: Math.min(width, height) }) 374 | } 375 | } 376 | 377 | const noop = () => { /* */ } 378 | -------------------------------------------------------------------------------- /src/Slider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Animated from 'react-native-reanimated' 3 | import { 4 | PanGestureHandler, 5 | TapGestureHandler, 6 | PanGestureHandlerGestureEvent, 7 | TapGestureHandlerGestureEvent 8 | } from 'react-native-gesture-handler' 9 | import { View, ViewStyle, StyleSheet } from 'react-native' 10 | import { LinearGradient, ImagePlaceholder } from 'react-native-image-filter-kit' 11 | 12 | type Props = { 13 | readonly width: number 14 | readonly height: number 15 | readonly thumbWidth: number 16 | readonly translate: Animated.Node 17 | readonly panGestureEvent: ( 18 | event: PanGestureHandlerGestureEvent | TapGestureHandlerGestureEvent 19 | ) => void 20 | readonly thumbColor: Animated.Node 21 | } 22 | 23 | type GradientProps = React.ComponentProps 24 | 25 | const colors: GradientProps['colors'] = ['#000000FF', '#00000000'] 26 | 27 | export const Slider = React.memo((props: Props) => { 28 | const { panGestureEvent, translate, thumbColor, width, height, thumbWidth } = 29 | props 30 | 31 | const imageWidth = width - thumbWidth 32 | const imageStyle = { width: imageWidth, height, borderRadius: 5 } 33 | const containerStyle = { width, height } 34 | const offset = -thumbWidth / 2 35 | 36 | return ( 37 | 38 | 39 | 44 | 45 | 46 | } 49 | /> 50 | 65 | 66 | 67 | 68 | 69 | 70 | ) 71 | }) 72 | 73 | type Styles = { 74 | readonly container: ViewStyle 75 | readonly slider: ViewStyle 76 | readonly thumb: ViewStyle 77 | } 78 | 79 | const styles = StyleSheet.create({ 80 | container: { 81 | justifyContent: 'center', 82 | alignItems: 'center' 83 | }, 84 | slider: { 85 | width: '100%', 86 | height: '100%', 87 | backgroundColor: 'white' 88 | }, 89 | thumb: { 90 | position: 'absolute', 91 | borderColor: 'white', 92 | borderWidth: 2, 93 | borderRadius: 5 94 | } 95 | }) 96 | -------------------------------------------------------------------------------- /src/ValueSlider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Animated from 'react-native-reanimated' 3 | import { 4 | View, 5 | ViewStyle, 6 | StyleSheet, 7 | LayoutChangeEvent, 8 | StyleProp 9 | } from 'react-native' 10 | import { 11 | PanGestureHandlerGestureEvent, 12 | TapGestureHandlerGestureEvent, 13 | State as GestureState 14 | } from 'react-native-gesture-handler' 15 | import { Slider } from './Slider' 16 | import { Size } from './size' 17 | import { defaultStyle } from './defaultStyle' 18 | 19 | const { 20 | Value, 21 | lessThan, 22 | greaterThan, 23 | cond, 24 | divide, 25 | color, 26 | event, 27 | block, 28 | set, 29 | and, 30 | neq, 31 | multiply, 32 | round, 33 | sub 34 | } = Animated 35 | 36 | type Props = { 37 | readonly style: StyleProp 38 | readonly initialValue?: number 39 | readonly onValueInit: ( 40 | value: Animated.Node, 41 | gestureState: Animated.Node 42 | ) => void 43 | readonly thumbWidth?: number 44 | } 45 | 46 | type State = { 47 | readonly width?: number 48 | readonly height?: number 49 | readonly pos?: Animated.Node 50 | readonly translate?: Animated.Node 51 | readonly value?: Animated.Node 52 | readonly thumbColor?: Animated.Node 53 | readonly panGestureEvent?: ( 54 | event: PanGestureHandlerGestureEvent | TapGestureHandlerGestureEvent 55 | ) => void 56 | readonly gestureState?: Animated.Node 57 | readonly codeKey?: number 58 | } 59 | 60 | export class ValueSlider extends React.PureComponent { 61 | static defaultProps: Partial = { 62 | thumbWidth: 50 63 | } 64 | 65 | constructor(props: Props) { 66 | super(props) 67 | 68 | const { style, thumbWidth, initialValue } = props 69 | const { width, height } = ValueSlider.size(style) 70 | 71 | this.state = 72 | width === undefined || height === undefined || thumbWidth === undefined 73 | ? {} 74 | : ValueSlider.state(width, height, thumbWidth, 0, initialValue) 75 | } 76 | 77 | private static state( 78 | width: number, 79 | height: number, 80 | thumbWidth: number, 81 | codeKey: number = 0, 82 | initialValue: number = 1 83 | ): Required { 84 | const size = width - thumbWidth 85 | const pos = new Value(size * initialValue) 86 | const gestureState = new Value(GestureState.UNDETERMINED) 87 | 88 | const translate: Animated.Node = cond( 89 | greaterThan(pos, size), 90 | size, 91 | cond(lessThan(pos, 0), 0, pos) 92 | ) 93 | 94 | const value = divide(translate, size) 95 | const col = round(multiply(value, 255)) 96 | 97 | return { 98 | codeKey: codeKey + 1, 99 | width, 100 | height, 101 | gestureState, 102 | pos, 103 | translate, 104 | value, 105 | // tslint:disable-next-line: no-any 106 | thumbColor: color(col, col, col) as any, 107 | panGestureEvent: event([ 108 | { 109 | nativeEvent: ({ 110 | x, 111 | state 112 | }: { 113 | x: number 114 | y: number 115 | state: GestureState 116 | }) => 117 | block([ 118 | set(gestureState, state), 119 | cond( 120 | and( 121 | neq(gestureState, GestureState.UNDETERMINED), 122 | neq(gestureState, GestureState.FAILED) 123 | ), 124 | set(pos, sub(x, thumbWidth / 2)) 125 | ) 126 | ]) 127 | } 128 | ]) 129 | } 130 | } 131 | 132 | private static size(style?: StyleProp): Size { 133 | if (style !== undefined) { 134 | const { width, height } = StyleSheet.flatten(style) 135 | 136 | if (typeof width === 'number' && typeof height === 'number') { 137 | return { width, height } 138 | } 139 | } 140 | 141 | return { width: undefined, height: undefined } 142 | } 143 | 144 | componentDidMount() { 145 | const { width, height, value } = this.state 146 | 147 | if (width !== undefined && height !== undefined && value !== undefined) { 148 | this.props.onValueInit(value, new Animated.Value(GestureState.END)) 149 | } 150 | } 151 | 152 | componentDidUpdate(prevProps: Props, prevState: State) { 153 | const { style, onValueInit, thumbWidth, initialValue } = this.props 154 | const { width, height } = StyleSheet.flatten(style) 155 | const { width: prevWidth, height: prevHeight } = StyleSheet.flatten( 156 | prevProps.style 157 | ) 158 | const size = ValueSlider.size(style) 159 | 160 | if (prevHeight !== height || prevWidth !== width) { 161 | this.setState(size) 162 | } else { 163 | const { width: curWidth = size.width, height: curHeight = size.height } = 164 | this.state 165 | 166 | if ( 167 | curWidth !== undefined && 168 | curHeight !== undefined && 169 | thumbWidth !== undefined && 170 | (curWidth !== prevState.width || 171 | curHeight !== prevState.height || 172 | thumbWidth !== prevProps.thumbWidth) 173 | ) { 174 | const state = ValueSlider.state( 175 | curWidth, 176 | curHeight, 177 | thumbWidth, 178 | prevState.codeKey, 179 | initialValue 180 | ) 181 | 182 | onValueInit(state.value, state.gestureState) 183 | this.setState(state) 184 | } 185 | } 186 | } 187 | 188 | render() { 189 | const { width, height, translate, thumbColor, panGestureEvent, codeKey } = 190 | this.state 191 | const { style, thumbWidth } = this.props 192 | 193 | return ( 194 | 199 | {width !== undefined && 200 | height !== undefined && 201 | thumbColor !== undefined && 202 | translate !== undefined && 203 | panGestureEvent !== undefined && 204 | thumbWidth !== undefined ? ( 205 | 213 | ) : ( 214 | false 215 | )} 216 | 217 | ) 218 | } 219 | 220 | private layout = ({ 221 | nativeEvent: { 222 | layout: { width, height } 223 | } 224 | }: LayoutChangeEvent) => { 225 | this.setState({ width, height }) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/Wheel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Animated from 'react-native-reanimated' 3 | import { 4 | TapGestureHandlerGestureEvent, 5 | PanGestureHandlerGestureEvent, 6 | TapGestureHandler, 7 | PanGestureHandler, 8 | State as GestureState 9 | } from 'react-native-gesture-handler' 10 | import { View, ViewStyle, StyleSheet } from 'react-native' 11 | import { 12 | SrcInComposition, 13 | SweepGradient, 14 | ImagePlaceholder, 15 | RadialGradient 16 | } from 'react-native-image-filter-kit' 17 | import { HueSaturationWheel } from './HueSaturationWheel' 18 | 19 | const { call, cond, eq, and, or, set } = Animated 20 | 21 | type Props = { 22 | readonly hue: Animated.Node 23 | readonly saturation: Animated.Node 24 | readonly value: Animated.Node 25 | readonly side: number 26 | readonly thumbSize: number 27 | readonly translateX: Animated.Node 28 | readonly translateY: Animated.Node 29 | readonly startX: Animated.Node 30 | readonly startY: Animated.Node 31 | readonly thumbColor: Animated.Node 32 | readonly wheelOpacity: Animated.Node 33 | readonly panGestureEvent: ( 34 | event: PanGestureHandlerGestureEvent | TapGestureHandlerGestureEvent 35 | ) => void 36 | readonly gestureState: Animated.Node 37 | readonly valueGestureState: Animated.Node 38 | readonly codeKey: number 39 | readonly onColorChangeComplete?: ([ 40 | hue, 41 | saturation, 42 | value 43 | ]: readonly number[]) => void 44 | readonly onColorChange?: ([hue, saturation, value]: readonly number[]) => void 45 | } 46 | 47 | type GradientProps = React.ComponentProps 48 | 49 | const colors: GradientProps['colors'] = [ 50 | '#FF0000', 51 | '#FFFF00', 52 | '#00FF00', 53 | '#00FFFF', 54 | '#0000FF', 55 | '#FF00FF', 56 | '#FF0000' 57 | ] 58 | const stops: GradientProps['stops'] = [0, 0.165, 0.33, 0.495, 0.66, 0.825, 1] 59 | const radialColors: GradientProps['colors'] = ['#00000000', '#000000FF'] 60 | 61 | export const Wheel = React.memo((props: Props) => { 62 | const { 63 | side, 64 | thumbSize, 65 | translateX, 66 | translateY, 67 | thumbColor, 68 | wheelOpacity, 69 | panGestureEvent, 70 | onColorChange, 71 | onColorChangeComplete, 72 | codeKey, 73 | gestureState, 74 | startX, 75 | startY, 76 | valueGestureState, 77 | hue, 78 | saturation, 79 | value 80 | } = props 81 | 82 | const thumbOffset = -thumbSize / 2 83 | const imageSide = side - thumbSize 84 | const containerStyle = { width: side, height: side } 85 | const imageStyle = { 86 | width: imageSide, 87 | height: imageSide, 88 | borderRadius: imageSide / 2 89 | } 90 | const thumbStyle = { 91 | width: thumbSize, 92 | height: thumbSize, 93 | borderRadius: thumbSize / 2 94 | } 95 | 96 | return ( 97 | 98 | 99 | 104 | 105 | 106 | } 112 | /> 113 | } 114 | dstImage={ 115 | } 118 | /> 119 | } 120 | /> 121 | 128 | 144 | {onColorChangeComplete !== undefined ? ( 145 | ( 148 | and( 149 | or( 150 | eq(gestureState, GestureState.END), 151 | eq(gestureState, GestureState.UNDETERMINED) 152 | ), 153 | or( 154 | eq(valueGestureState, GestureState.END), 155 | eq(valueGestureState, GestureState.UNDETERMINED) 156 | ), 157 | or( 158 | HueSaturationWheel.isGestureStartedInsideCircle( 159 | gestureState, 160 | startX, 161 | startY, 162 | thumbSize, 163 | side 164 | ), 165 | eq(valueGestureState, GestureState.END) 166 | ) 167 | ), 168 | [ 169 | set( 170 | valueGestureState as Animated.Value, 171 | GestureState.UNDETERMINED 172 | ), 173 | call([hue, saturation, value], onColorChangeComplete) 174 | ] 175 | )} 176 | /> 177 | ) : ( 178 | false 179 | )} 180 | {onColorChange !== undefined ? ( 181 | 198 | ) : ( 199 | false 200 | )} 201 | 202 | 203 | 204 | 205 | 206 | ) 207 | }) 208 | 209 | type Styles = { 210 | readonly container: ViewStyle 211 | readonly thumb: ViewStyle 212 | readonly wheel: ViewStyle 213 | readonly wheelOverlay: ViewStyle 214 | } 215 | 216 | const styles = StyleSheet.create({ 217 | container: { 218 | justifyContent: 'center', 219 | alignItems: 'center' 220 | }, 221 | wheel: { 222 | width: '100%', 223 | height: '100%', 224 | backgroundColor: 'white' 225 | }, 226 | wheelOverlay: { 227 | position: 'absolute', 228 | backgroundColor: 'black' 229 | }, 230 | thumb: { 231 | position: 'absolute', 232 | borderColor: 'white', 233 | borderWidth: 2 234 | } 235 | }) 236 | -------------------------------------------------------------------------------- /src/colorHSV.ts: -------------------------------------------------------------------------------- 1 | import Animated from 'react-native-reanimated' 2 | 3 | const { 4 | multiply, 5 | divide, 6 | abs, 7 | sub, 8 | modulo, 9 | color, 10 | round, 11 | add, 12 | cond, 13 | lessThan, 14 | proc 15 | } = Animated 16 | 17 | export const colorHSV = proc( 18 | ( 19 | h: Animated.Adaptable, 20 | s: Animated.Adaptable, 21 | v: Animated.Adaptable 22 | ): Animated.Node => { 23 | // Converts color from HSV format into RGB 24 | // Formula explained here: https://www.rapidtables.com/convert/color/hsv-to-rgb.html 25 | const c = multiply(v, s) 26 | const hh = divide(h, 60) 27 | const x = multiply(c, sub(1, abs(sub(modulo(hh, 2), 1)))) 28 | 29 | const m = sub(v, c) 30 | 31 | const colorRGB = ( 32 | r: Animated.Adaptable, 33 | g: Animated.Adaptable, 34 | b: Animated.Adaptable 35 | ) => 36 | color( 37 | round(multiply(255, add(r, m))), 38 | round(multiply(255, add(g, m))), 39 | round(multiply(255, add(b, m))) 40 | ) 41 | 42 | // @ts-expect-error 43 | return cond( 44 | lessThan(h, 60), 45 | colorRGB(c, x, 0), 46 | cond( 47 | lessThan(h, 120), 48 | colorRGB(x, c, 0), 49 | cond( 50 | lessThan(h, 180), 51 | colorRGB(0, c, x), 52 | cond( 53 | lessThan(h, 240), 54 | colorRGB(0, x, c), 55 | cond(lessThan(h, 300), colorRGB(x, 0, c), colorRGB(c, 0, x)) 56 | ) 57 | ) 58 | ) 59 | ) 60 | } 61 | ) 62 | -------------------------------------------------------------------------------- /src/defaultStyle.ts: -------------------------------------------------------------------------------- 1 | import { ViewStyle, StyleSheet } from 'react-native' 2 | 3 | type Styles = { 4 | readonly container: ViewStyle 5 | } 6 | 7 | export const defaultStyle = StyleSheet.create({ 8 | container: { 9 | width: '100%', 10 | height: '100%' 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { HueSaturationValuePicker } from './HueSaturationValuePicker' 2 | export { HueSaturationWheel, HSV } from './HueSaturationWheel' 3 | export { ValueSlider } from './ValueSlider' 4 | -------------------------------------------------------------------------------- /src/size.ts: -------------------------------------------------------------------------------- 1 | export type Size = { 2 | readonly width: number 3 | readonly height: number 4 | } | { 5 | readonly width: undefined 6 | readonly height: undefined 7 | } 8 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "target": "es6", 5 | "strict": true, 6 | "outDir": "../dist", 7 | "jsx": "react-native", 8 | "lib": ["es6"], 9 | "moduleResolution": "node", 10 | "allowSyntheticDefaultImports": true, 11 | "declaration": true, 12 | "types": ["react-native"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-standard", "tslint-react"], 3 | "rules": { 4 | "strict-boolean-expressions": true, 5 | "jsx-no-multiline-js": false, 6 | "jsx-space-before-trailing-slash": true, 7 | "no-any": true, 8 | "no-parameter-reassignment": true, 9 | "no-shadowed-variable": true, 10 | "max-line-length": [true, 100], 11 | "max-file-line-count": [true, 300], 12 | "ter-indent": [true, 2], 13 | "space-before-function-paren": [true, { 14 | "anonymous": "always", 15 | "named": "never", 16 | "asyncArrow": "always" 17 | }] 18 | } 19 | } 20 | --------------------------------------------------------------------------------