diff --git a/package.json b/package.json index 42e74b9..85ebbc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wowmaking/react-native-ios-scroll-picker", - "version": "1.0.3", + "version": "1.0.6", "description": "Scroll picker like IOS", "scripts": { "build": "tsc --project ./tsconfig.json", @@ -40,7 +40,7 @@ "react-native-builder-bob": "^0.18.1", "react-native-gesture-handler": "^1.10.3", "react-native-haptic-feedback": "^1.11.0", - "react-native-reanimated": "^2.2.2", + "react-native-reanimated": "^2.3.1", "typescript": "^4.4.3" }, "main": "lib/module/index.js", @@ -70,4 +70,4 @@ "lib/" ], "license": "MIT" -} +} \ No newline at end of file diff --git a/src/animationHelpers.js b/src/animationHelpers.js new file mode 100644 index 0000000..ead437c --- /dev/null +++ b/src/animationHelpers.js @@ -0,0 +1,60 @@ +import { Clock, Value, add, block, cond, eq, set, startClock, and, not, clockRunning, timing, EasingNode, stopClock, or, call, } from 'react-native-reanimated'; +import { State } from 'react-native-gesture-handler'; +import RNHapticFeedback from 'react-native-haptic-feedback'; +import _throttle from 'lodash/throttle'; +import { snapPoint } from './redash'; +export const withDecay = (params) => { + const { itemHeight, value, velocity, state: gestureState, offset, snapPoints, values, defaultValue = 1 } = { + ...params, + }; + const init = new Value(0); + const clock = new Clock(); + const state = { + finished: new Value(0), + position: new Value(0), + time: new Value(0), + frameTime: new Value(0), + }; + const config = { + toValue: new Value(0), + duration: new Value(1000), + easing: EasingNode.bezier(0.22, 1, 0.36, 1), + }; + const defaultIndex = values.findIndex((v) => v.value === defaultValue); + const vibrate = _throttle(() => { + RNHapticFeedback.trigger('selection', { + enableVibrateFallback: false, + ignoreAndroidSystemSettings: false + }); + }, 100); + let val = defaultValue; + return block([ + cond(not(init), [ + set(offset, -itemHeight * defaultIndex), + set(state.position, offset), + set(init, 1), + ]), + cond(eq(gestureState, State.BEGAN), set(offset, state.position)), + cond(eq(gestureState, State.ACTIVE), [ + set(state.position, add(offset, value)), + set(state.time, 0), + set(state.frameTime, 0), + set(state.finished, 0), + set(config.toValue, snapPoint(state.position, velocity, snapPoints)), + ]), + cond(and(not(state.finished), eq(gestureState, State.END)), [ + cond(not(clockRunning(clock)), [startClock(clock)]), + timing(clock, state, config), + cond(state.finished, stopClock(clock)), + ]), + cond(or(eq(gestureState, State.ACTIVE), state.finished), call([state.position], (currentValue) => { + const selectedIndex = Math.round(-currentValue / itemHeight); + const newValue = values[selectedIndex]?.value; + if (newValue && newValue !== val) { + val = newValue; + vibrate(); + } + })), + state.position, + ]); +}; diff --git a/src/gestureHandler.js b/src/gestureHandler.js new file mode 100644 index 0000000..da86022 --- /dev/null +++ b/src/gestureHandler.js @@ -0,0 +1,32 @@ +import React, { useMemo } from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { useCode, set, Value, add, call, } from 'react-native-reanimated'; +import { PanGestureHandler } from 'react-native-gesture-handler'; +import { usePanGestureHandler } from './redash'; +import { withDecay } from './animationHelpers'; +const GestureHandler = ({ value, max, onValueChange, defaultValue, values, visibleItems, itemHeight }) => { + const { gestureHandler, translation, velocity, state, } = usePanGestureHandler(); + const snapPoints = new Array(max).fill(0).map((_, i) => i * -itemHeight); + const translateY = useMemo(() => withDecay({ + itemHeight, + value: translation.y, + velocity: velocity.y, + state, + snapPoints, + defaultValue, + values, + offset: new Value(0), + }), [values]); + useCode(() => [set(value, add(translateY, itemHeight * Math.floor(visibleItems / 2)))], []); + useCode(() => call([translateY], ([currentValue]) => { + const selectedIndex = Math.round(-currentValue / itemHeight); + const newValue = values[selectedIndex]?.value; + if (typeof onValueChange === 'function' && newValue !== null && newValue !== undefined) { + onValueChange(newValue); + } + }), [translateY]); + return ( + + ); +}; +export default GestureHandler; diff --git a/src/gestureHandler.tsx b/src/gestureHandler.tsx index 7fba9af..1ad5a3c 100644 --- a/src/gestureHandler.tsx +++ b/src/gestureHandler.tsx @@ -43,7 +43,7 @@ const GestureHandler = ({ value, max, onValueChange, defaultValue, values, visib values, offset: new Value(0), }), - [values], + [values, defaultValue], ); useCode(() => [set(value, add(translateY, itemHeight * Math.floor(visibleItems / 2)))], []); @@ -52,7 +52,7 @@ const GestureHandler = ({ value, max, onValueChange, defaultValue, values, visib const selectedIndex = Math.round(-currentValue / itemHeight); const newValue = values[selectedIndex]?.value; - if (typeof onValueChange === 'function' && newValue) { + if (typeof onValueChange === 'function' && newValue !== null && newValue !== undefined) { onValueChange(newValue); } }), [translateY]); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..43322c5 --- /dev/null +++ b/src/index.js @@ -0,0 +1,2 @@ +import Picker from './picker'; +export default Picker; diff --git a/src/picker.js b/src/picker.js new file mode 100644 index 0000000..9981a16 --- /dev/null +++ b/src/picker.js @@ -0,0 +1,81 @@ +import React, { useMemo } from 'react'; +import { View, StyleSheet, Text } from 'react-native'; +import Animated, { interpolateNode, Extrapolate, sub, divide, useValue, multiply, asin, cos, } from 'react-native-reanimated'; +import { translateZ } from './redash'; +import GestureHandler from './gestureHandler'; +const perspective = 1600; +const Picker = ({ containerWidth, values, defaultValue, visibleItems, itemHeight, onChange, withTranslateZ, withScale, withOpacity, deviderStyle, labelStyle, }) => { + const translateY = useValue(0); + const roundedItems = Math.floor(visibleItems / 2); + const radiusRel = visibleItems * 0.5; + const radius = radiusRel * itemHeight; + const renderItems = useMemo(() => ( + {values.map((v, i) => { + const transform = []; + const node = divide(sub(translateY, itemHeight * roundedItems), -itemHeight); + const opacity = !withOpacity ? 1 : interpolateNode(node, { + inputRange: [i - visibleItems, i, i + visibleItems], + outputRange: [0.2, 1, 0.2], + extrapolate: Extrapolate.CLAMP, + }); + if (withScale) { + const scale = interpolateNode(node, { + inputRange: [i - visibleItems, i, i + visibleItems], + outputRange: [0.5, 1, 0.5], + extrapolate: Extrapolate.CLAMP, + }); + transform.push({ scale }); + } + if (withTranslateZ) { + transform.push({ perspective }); + const y = interpolateNode(node, { + inputRange: [i - radiusRel, i, i + radiusRel], + outputRange: [-1, 0, 1], + extrapolate: Extrapolate.CLAMP, + }); + const rotateX = asin(y); + transform.push({ rotateX }); + const z = sub(multiply(radius, cos(rotateX)), radius); + transform.push(translateZ(perspective, z)); + } + return ( + {v.label} + ); + })} + ), []); + return ( + + + + {renderItems} + + ); +}; +export default Picker; +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + }, + item: { + justifyContent: 'center', + }, + label: { + color: '#000000', + fontWeight: '500', + fontSize: 24, + textAlign: 'center', + textAlignVertical: 'center', + }, +}); diff --git a/src/picker.tsx b/src/picker.tsx index 93466ed..6204d32 100644 --- a/src/picker.tsx +++ b/src/picker.tsx @@ -49,7 +49,7 @@ const Picker = ({ const radius = radiusRel * itemHeight; const renderItems = useMemo(() => ( - + {values.map((v, i) => { const transform = []; const node = divide(sub(translateY, itemHeight * roundedItems), -itemHeight); diff --git a/src/redash/index.js b/src/redash/index.js new file mode 100644 index 0000000..1102260 --- /dev/null +++ b/src/redash/index.js @@ -0,0 +1,62 @@ +import { useRef } from "react"; +import Animated from "react-native-reanimated"; +import { State } from "react-native-gesture-handler"; +const { divide, sub, Value, event, multiply, add, min, abs, cond, eq } = Animated; +export const translateZ = (perspective, z) => ({ scale: divide(perspective, sub(perspective, z)) }); +function useConst(initialValue) { + const ref = useRef(); + if (ref.current === undefined) { + // Box the value in an object so we can tell if it's initialized even if the initializer + // returns/is undefined + ref.current = { + value: typeof initialValue === "function" + ? // eslint-disable-next-line @typescript-eslint/ban-types + initialValue() + : initialValue, + }; + } + return ref.current.value; +} +const create = (x, y) => ({ + x: x ?? 0, + y: y ?? x ?? 0, +}); +const createValue = (x = 0, y) => create(new Value(x), new Value(y ?? x)); +const onGestureEvent = (nativeEvent) => { + const gestureEvent = event([{ nativeEvent }]); + return { + onHandlerStateChange: gestureEvent, + onGestureEvent: gestureEvent, + }; +}; +const panGestureHandler = () => { + const position = createValue(0); + const translation = createValue(0); + const velocity = createValue(0); + const state = new Value(State.UNDETERMINED); + const gestureHandler = onGestureEvent({ + x: position.x, + translationX: translation.x, + velocityX: velocity.x, + y: position.y, + translationY: translation.y, + velocityY: velocity.y, + state, + }); + return { + position, + translation, + velocity, + state, + gestureHandler, + }; +}; +export const usePanGestureHandler = () => useConst(() => panGestureHandler()); +export const snapPoint = (value, velocity, points) => { + const point = add(value, multiply(0.2, velocity)); + const diffPoint = (p) => abs(sub(point, p)); + const deltas = points.map((p) => diffPoint(p)); + // @ts-ignore + const minDelta = min(...deltas); + return points.reduce((acc, p) => cond(eq(diffPoint(p), minDelta), p, acc), new Value()); +};