UNPKG

react-native-ui-lib

Version:

[![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg)](https://stand-with-ukraine.pp.ua)

644 lines (630 loc) • 18.2 kB
import _isFunction from "lodash/isFunction"; import React, { PureComponent } from 'react'; import { StyleSheet, PanResponder, AccessibilityInfo, Animated } from 'react-native'; import { Constants, asBaseComponent } from "../../commons/new"; import { extractAccessibilityProps } from "../../commons/modifiers"; import { Colors } from "../../style"; import View from "../view"; import IncubatorSlider from "../../incubator/slider"; import Thumb from "./Thumb"; const TRACK_SIZE = 6; const THUMB_SIZE = 24; const SHADOW_RADIUS = 4; const DEFAULT_COLOR = Colors.$backgroundDisabled; const ACTIVE_COLOR = Colors.$backgroundPrimaryHeavy; const INACTIVE_COLOR = Colors.$backgroundNeutralMedium; const MIN_RANGE_GAP = 4; const defaultProps = { value: 0, minimumValue: 0, maximumValue: 1, step: 0, thumbHitSlop: { top: 10, bottom: 10, left: 24, right: 24 }, useGap: true }; /** * @description: A Slider component * @example: https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/componentScreens/SliderScreen.tsx * @gif: https://github.com/wix/react-native-ui-lib/blob/master/demo/showcase/Slider/Slider.gif?raw=true */ class Slider extends PureComponent { static displayName = 'Slider'; static defaultProps = defaultProps; thumb = React.createRef(); minThumb = React.createRef(); minTrack = React.createRef(); _minTrackStyles = {}; _x = 0; _x_min = 0; lastDx = 0; initialValue = this.getRoundedValue(this.getInitialValue()); minInitialValue = this.getRoundedValue(this.props.initialMinimumValue || this.props.minimumValue); lastValue = this.initialValue; lastMinValue = this.minInitialValue; _thumbStyles = {}; _minThumbStyles = { left: this.minInitialValue }; initialThumbSize = { width: THUMB_SIZE, height: THUMB_SIZE }; constructor(props) { super(props); this.activeThumbRef = this.thumb; this.didMount = false; this.state = { containerSize: { width: 0, height: 0 }, trackSize: { width: 0, height: 0 }, thumbSize: { width: 0, height: 0 }, thumbActiveAnimation: new Animated.Value(1), measureCompleted: false }; this.checkProps(props); this.panResponder = PanResponder.create({ onMoveShouldSetPanResponder: this.handleMoveShouldSetPanResponder, onPanResponderGrant: this.handlePanResponderGrant, onPanResponderMove: this.handlePanResponderMove, onPanResponderRelease: this.handlePanResponderEnd, onStartShouldSetPanResponder: () => true, onPanResponderEnd: () => true, onPanResponderTerminationRequest: () => false }); } reset() { // NOTE: used with ref this.lastValue = this.initialValue; this.lastMinValue = this.minInitialValue; this.lastDx = 0; this.setActiveThumb(this.thumb); this.set_x(this.getXForValue(this.initialValue)); this.moveTo(this._x); if (this.props.useRange) { this.setActiveThumb(this.minThumb); this.set_x(this.getXForValue(this.minInitialValue)); this.moveMinTo(this._x_min); } this.props.onReset?.(); } getInitialValue() { const { useRange, initialMaximumValue, value, maximumValue } = this.props; return useRange ? initialMaximumValue || maximumValue : value; } checkProps(props) { const { useRange, minimumValue, maximumValue, value } = props; if (minimumValue > maximumValue) { console.warn('Slider minimumValue must be lower than maximumValue'); } if (!useRange && (value < minimumValue || value > maximumValue)) { console.warn('Slider value is not in range'); } } getAccessibilityProps() { const { disabled } = this.props; return { accessibilityLabel: 'Slider', accessible: true, accessibilityRole: 'adjustable', accessibilityState: disabled ? { disabled } : undefined, accessibilityActions: [{ name: 'increment', label: 'increment' }, { name: 'decrement', label: 'decrement' }], ...extractAccessibilityProps(this.props) }; } componentDidUpdate(prevProps, prevState) { const { useRange, value, initialMinimumValue } = this.props; if (!useRange && prevProps.value !== value) { this.initialValue = this.getRoundedValue(value); // set position for new value this._x = this.getXForValue(this.initialValue); this.moveTo(this._x); } if (prevState.measureCompleted !== this.state.measureCompleted) { this.initialThumbSize = this.state.thumbSize; // for thumb enlargement // set initial position this._x = this.getXForValue(this.initialValue); this._x_min = this.getXForValue(this.minInitialValue); this.moveTo(this._x); if (useRange && initialMinimumValue) { this.moveMinTo(this._x_min); } this.didMount = true; } } componentDidMount() { this.dimensionsChangeListener = Constants.addDimensionsEventListener(this.onOrientationChanged); } componentWillUnmount() { Constants.removeDimensionsEventListener(this.dimensionsChangeListener || this.onOrientationChanged); } /* Gesture Recognizer */ handleMoveShouldSetPanResponder = () => { return true; }; handlePanResponderGrant = () => { this.lastDx = 0; this.onSeekStart(); }; handlePanResponderMove = (_e, gestureState) => { const { disabled } = this.props; if (disabled) { return; } // dx = accumulated distance since touch start const dx = gestureState.dx * (Constants.isRTL && !this.disableRTL ? -1 : 1); this.update(dx - this.lastDx); this.lastDx = dx; }; handlePanResponderEnd = () => { this.bounceToStep(); this.onSeekEnd(); }; /* Actions */ setActiveThumb = ref => { this.activeThumbRef = ref; }; get_x() { if (this.isDefaultThumbActive()) { return this._x; } else { return this._x_min; } } set_x(x) { if (this.isDefaultThumbActive()) { this._x = x; } else { this._x_min = x; } } update(dx) { // calc x in range (instead of: this._x += dx) let x = this.get_x(); x += dx; x = Math.max(Math.min(x, this.state.trackSize.width), 0); this.set_x(x); this.moveTo(x); if (!this.props.useRange) { this.updateValue(x); } } bounceToStep() { if (this.props.step > 0) { const value = this.getValueForX(this.get_x()); const roundedValue = this.getRoundedValue(value); const x = this.getXForValue(roundedValue); this.set_x(x); this.moveTo(x); } } updateValue(x) { const value = this.getValueForX(x); if (this.props.useRange) { this.onRangeChange(value); } else { this.onValueChange(value); } } moveTo(x) { if (this.isDefaultThumbActive()) { if (this.thumb.current) { const { useRange, useGap } = this.props; const { trackSize, thumbSize } = this.state; const nonOverlappingTrackWidth = trackSize.width - this.initialThumbSize.width; const _x = this.shouldForceLTR ? trackSize.width - x : x; // adjust for RTL const left = trackSize.width === 0 ? _x : _x * nonOverlappingTrackWidth / trackSize.width; // do not render above prefix\suffix icon\text if (useRange) { const minThumbPosition = this._minThumbStyles?.left; if (useGap && left > minThumbPosition + thumbSize.width + MIN_RANGE_GAP || !useGap && left >= minThumbPosition) { this._thumbStyles.left = left; const width = left - minThumbPosition; this._minTrackStyles.width = width; if (this.didMount) { this.updateValue(x); } } } else { this._thumbStyles.left = left; this._minTrackStyles.width = Math.min(trackSize.width, x); } this.thumb.current?.setNativeProps?.(this._thumbStyles); this.minTrack.current?.setNativeProps?.(this._minTrackStyles); } } else { this.moveMinTo(x); } } moveMinTo(x) { const { useGap } = this.props; const { trackSize, thumbSize } = this.state; if (this.minThumb.current) { const nonOverlappingTrackWidth = trackSize.width - this.initialThumbSize.width; const _x = this.shouldForceLTR ? nonOverlappingTrackWidth - x : x; // adjust for RTL const left = trackSize.width === 0 ? _x : _x * nonOverlappingTrackWidth / trackSize.width; // do not render above prefix\suffix icon\text const maxThumbPosition = this._thumbStyles?.left; if (useGap && left < maxThumbPosition - thumbSize.width - MIN_RANGE_GAP || !useGap && left <= maxThumbPosition) { this._minThumbStyles.left = left; this._minTrackStyles.width = maxThumbPosition - x; this._minTrackStyles.left = x; this.minThumb.current?.setNativeProps?.(this._minThumbStyles); this.minTrack.current?.setNativeProps?.(this._minTrackStyles); if (this.didMount) { this.updateValue(x); } } } } updateTrackStepAndStyle = ({ nativeEvent }) => { const { step, useRange } = this.props; const { trackSize } = this.state; const newX = Constants.isRTL && !this.disableRTL ? trackSize.width - nativeEvent.locationX : nativeEvent.locationX; if (useRange) { if (this.isDefaultThumbActive() && this._minThumbStyles?.left && newX < this._minThumbStyles?.left) { // new x is smaller then min but the active thumb is the max this.setActiveThumb(this.minThumb); } else if (!this.isDefaultThumbActive() && this._thumbStyles.left && newX > this._thumbStyles.left) { // new x is bigger then max but the active thumb is the min this.setActiveThumb(this.thumb); } } this.set_x(newX); if (!useRange) { this.updateValue(this.get_x()); } if (step > 0) { this.bounceToStep(); } else { this.moveTo(this.get_x()); } }; /** Values */ get disableRTL() { const { disableRTL, useRange } = this.props; if (useRange) { // block forceRTL on range slider return false; } return disableRTL; } shouldForceLTR = Constants.isRTL && this.disableRTL; isDefaultThumbActive = () => { return this.activeThumbRef === this.thumb; }; getRoundedValue(value) { const { step } = this.props; const v = this.getValueInRange(value); return step > 0 ? Math.round(v / step) * step : v; } getValueInRange(value) { const { minimumValue, maximumValue } = this.props; const v = value < minimumValue ? minimumValue : value > maximumValue ? maximumValue : value; return v; } getXForValue(value) { const { minimumValue } = this.props; const range = this.getRange(); const relativeValue = minimumValue - value; const v = minimumValue < 0 ? Math.abs(relativeValue) : value - minimumValue; // for negatives const ratio = v / range; const x = ratio * this.state.trackSize.width; return x; } getValueForX(x) { const { maximumValue, minimumValue, step } = this.props; const ratio = x / (this.state.trackSize.width - this.initialThumbSize.width / 2); const range = this.getRange(); if (step) { return Math.max(minimumValue, Math.min(maximumValue, minimumValue + Math.round(ratio * range / step) * step)); } else { return Math.max(minimumValue, Math.min(maximumValue, ratio * range + minimumValue)); } } getRange() { const { minimumValue, maximumValue } = this.props; const range = maximumValue - minimumValue; return range; } /* Events */ onOrientationChanged = () => { this.initialValue = this.lastValue; this.minInitialValue = this.lastMinValue; this.setState({ measureCompleted: false }); }; onRangeChange = value => { if (this.isDefaultThumbActive()) { this.lastValue = value; } else { this.lastMinValue = value; } let values = { min: this.lastMinValue, max: this.lastValue }; if (Constants.isRTL && this.props.disableRTL) { // forceRTL for range slider const { maximumValue } = this.props; values = { min: maximumValue - this.lastValue, max: maximumValue - this.lastMinValue }; } this.props.onRangeChange?.(values); }; onValueChange = value => { this.lastValue = value; this.props.onValueChange?.(value); }; onSeekStart() { this.props.onSeekStart?.(); } onSeekEnd() { this.props.onSeekEnd?.(); } onContainerLayout = nativeEvent => { this.handleMeasure('containerSize', nativeEvent); }; onTrackLayout = nativeEvent => { this.setState({ measureCompleted: false }); this.handleMeasure('trackSize', nativeEvent); }; onThumbLayout = nativeEvent => { this.handleMeasure('thumbSize', nativeEvent); }; handleTrackPress = event => { if (this.props.disabled) { return; } this.onSeekStart(); this.updateTrackStepAndStyle(event); this.onSeekEnd(); }; handleMeasure = (name, { nativeEvent }) => { const { width, height } = nativeEvent.layout; const size = { width, height }; const currentSize = this[name]; if (currentSize && width === currentSize.width && height === currentSize.height) { return; } this[name] = size; if (this.containerSize && this.thumbSize && this.trackSize) { this.setState({ containerSize: this.containerSize, trackSize: this.trackSize, thumbSize: this.thumbSize }, () => { this.setState({ measureCompleted: true }); }); } }; onAccessibilityAction = event => { const { maximumValue, minimumValue, step } = this.props; const value = this.getValueForX(this._x); let newValue; switch (event.nativeEvent.actionName) { case 'increment': newValue = value !== maximumValue ? value + step : value; break; case 'decrement': newValue = value !== minimumValue ? value - step : value; break; default: newValue = value; break; } this._x = this.getXForValue(newValue); this.updateValue(this._x); this.moveTo(this._x); AccessibilityInfo.announceForAccessibility?.(`New value ${newValue}`); }; onMinTouchStart = () => { this.setActiveThumb(this.minThumb); }; onTouchStart = () => { this.setActiveThumb(this.thumb); }; getThumbProps = () => { const { thumbStyle, activeThumbStyle, disableActiveStyling, disabled, thumbTintColor, thumbHitSlop } = this.props; const { thumbSize } = this.state; const verticalHitslop = Math.max(0, (48 - thumbSize.height) / 2); const horizontalHitslop = Math.max(0, (48 - thumbSize.width) / 2); const calculatedHitSlop = thumbSize.width > 0 ? { top: verticalHitslop, bottom: verticalHitslop, left: horizontalHitslop, right: horizontalHitslop } : undefined; return { disabled, thumbTintColor, thumbStyle, activeThumbStyle, disableActiveStyling, thumbHitSlop: thumbHitSlop ?? calculatedHitSlop, onLayout: this.onThumbLayout }; }; /* Renders */ renderMinThumb = () => { return <Thumb {...this.getThumbProps()} ref={this.minThumb} onTouchStart={this.onMinTouchStart} {...this.panResponder.panHandlers} />; }; renderThumb = () => { return <Thumb {...this.getThumbProps()} ref={this.thumb} onTouchStart={this.onTouchStart} {...this.panResponder.panHandlers} />; }; renderTrack() { const { trackStyle, renderTrack, disabled, minimumTrackTintColor = ACTIVE_COLOR, maximumTrackTintColor = DEFAULT_COLOR } = this.props; return _isFunction(renderTrack) ? <View style={[styles.track, { backgroundColor: maximumTrackTintColor }, trackStyle]} onLayout={this.onTrackLayout}> {renderTrack()} </View> : <View> <View style={[styles.track, trackStyle, { backgroundColor: disabled ? INACTIVE_COLOR : maximumTrackTintColor }]} onLayout={this.onTrackLayout} /> <View ref={this.minTrack} style={[styles.track, trackStyle, styles.minimumTrack, this.shouldForceLTR && styles.trackDisableRTL, { backgroundColor: disabled ? DEFAULT_COLOR : minimumTrackTintColor }]} /> </View>; } renderRangeThumb() { const { useRange, useGap } = this.props; if (useRange) { if (useGap) { return this.renderMinThumb(); } return <View style={{ zIndex: this.isDefaultThumbActive() ? 0 : 1, top: '-50%' }}>{this.renderMinThumb()}</View>; } } render() { const { containerStyle, testID, migrate } = this.props; if (migrate) { return <IncubatorSlider {...this.props} />; } return <View style={[styles.container, containerStyle]} onLayout={this.onContainerLayout} onAccessibilityAction={this.onAccessibilityAction} testID={testID} {...this.getAccessibilityProps()}> {this.renderTrack()} <View style={styles.touchArea} onTouchEnd={this.handleTrackPress} /> {this.renderRangeThumb()} {this.renderThumb()} </View>; } } export default asBaseComponent(Slider); const styles = StyleSheet.create({ container: { height: THUMB_SIZE + SHADOW_RADIUS, justifyContent: 'center' }, track: { height: TRACK_SIZE, borderRadius: TRACK_SIZE / 2, overflow: 'hidden' }, minimumTrack: { position: 'absolute' }, trackDisableRTL: { right: 0 }, touchArea: { ...StyleSheet.absoluteFillObject, backgroundColor: 'transparent' } });