diff --git a/src/slider/Slider.js b/src/slider/Slider.js new file mode 100644 index 0000000000..d534825024 --- /dev/null +++ b/src/slider/Slider.js @@ -0,0 +1,533 @@ +'use strict'; + +import React, { + Component, + PropTypes +} from "react"; + +import { + Animated, + StyleSheet, + PanResponder, + View, + Easing +} from "react-native"; + +const shallowCompare = require('react-addons-shallow-compare'), + styleEqual = require('style-equal'); + +var TRACK_SIZE = 4; +var THUMB_SIZE = 20; + +function Rect(x, y, width, height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; +} + +Rect.prototype.containsPoint = function(x, y) { + return (x >= this.x + && y >= this.y + && x <= this.x + this.width + && y <= this.y + this.height); +}; + +var DEFAULT_ANIMATION_CONFIGS = { + spring : { + friction : 7, + tension : 100 + }, + timing : { + duration : 150, + easing : Easing.inOut(Easing.ease), + delay : 0 + }, + // decay : { // This has a serious bug + // velocity : 1, + // deceleration : 0.997 + // } +}; + +var Slider = React.createClass({ + propTypes: { + /** + * Initial value of the slider. The value should be between minimumValue + * and maximumValue, which default to 0 and 1 respectively. + * Default value is 0. + * + * *This is not a controlled component*, e.g. if you don't update + * the value, the component won't be reset to its inital value. + */ + value: PropTypes.number, + + /** + * If true the user won't be able to move the slider. + * Default value is false. + */ + disabled: PropTypes.bool, + + /** + * Initial minimum value of the slider. Default value is 0. + */ + minimumValue: PropTypes.number, + + /** + * Initial maximum value of the slider. Default value is 1. + */ + maximumValue: PropTypes.number, + + /** + * Step value of the slider. The value should be between 0 and + * (maximumValue - minimumValue). Default value is 0. + */ + step: PropTypes.number, + + /** + * The color used for the track to the left of the button. Overrides the + * default blue gradient image. + */ + minimumTrackTintColor: PropTypes.string, + + /** + * The color used for the track to the right of the button. Overrides the + * default blue gradient image. + */ + maximumTrackTintColor: PropTypes.string, + + /** + * The color used for the thumb. + */ + thumbTintColor: PropTypes.string, + + /** + * The size of the touch area that allows moving the thumb. + * The touch area has the same center has the visible thumb. + * This allows to have a visually small thumb while still allowing the user + * to move it easily. + * The default is {width: 40, height: 40}. + */ + thumbTouchSize: PropTypes.shape( + {width: PropTypes.number, height: PropTypes.number} + ), + + /** + * Callback continuously called while the user is dragging the slider. + */ + onValueChange: PropTypes.func, + + /** + * Callback called when the user starts changing the value (e.g. when + * the slider is pressed). + */ + onSlidingStart: PropTypes.func, + + /** + * Callback called when the user finishes changing the value (e.g. when + * the slider is released). + */ + onSlidingComplete: PropTypes.func, + + /** + * The style applied to the slider container. + */ + style: View.propTypes.style, + + /** + * The style applied to the track. + */ + trackStyle: View.propTypes.style, + + /** + * The style applied to the thumb. + */ + thumbStyle: View.propTypes.style, + + /** + * Set this to true to visually see the thumb touch rect in green. + */ + debugTouchArea: PropTypes.bool, + + /** + * Set to true to animate values with default 'timing' animation type + */ + animateTransitions : PropTypes.bool, + + /** + * Custom Animation type. 'spring' or 'timing'. + */ + animationType : PropTypes.oneOf(['spring', 'timing']), + + /** + * Used to configure the animation parameters. These are the same parameters in the Animated library. + */ + animationConfig : PropTypes.object, + }, + getInitialState() { + return { + containerSize: {width: 0, height: 0}, + trackSize: {width: 0, height: 0}, + thumbSize: {width: 0, height: 0}, + allMeasured: false, + value: new Animated.Value(this.props.value), + }; + }, + getDefaultProps() { + return { + value: 0, + minimumValue: 0, + maximumValue: 1, + step: 0, + minimumTrackTintColor: '#3f3f3f', + maximumTrackTintColor: '#b3b3b3', + thumbTintColor: '#343434', + thumbTouchSize: {width: 40, height: 40}, + debugTouchArea: false, + animationType: 'timing' + }; + }, + componentWillMount() { + this._panResponder = PanResponder.create({ + onStartShouldSetPanResponder: this._handleStartShouldSetPanResponder, + onMoveShouldSetPanResponder: this._handleMoveShouldSetPanResponder, + onPanResponderGrant: this._handlePanResponderGrant, + onPanResponderMove: this._handlePanResponderMove, + onPanResponderRelease: this._handlePanResponderEnd, + onPanResponderTerminationRequest: this._handlePanResponderRequestEnd, + onPanResponderTerminate: this._handlePanResponderEnd, + }); + }, + componentWillReceiveProps: function(nextProps) { + var newValue = nextProps.value; + + if (this.props.value !== newValue) { + if (this.props.animateTransitions) { + this._setCurrentValueAnimated(newValue); + } + else { + this._setCurrentValue(newValue); + } + } + }, + shouldComponentUpdate: function(nextProps, nextState) { + // We don't want to re-render in the following cases: + // - when only the 'value' prop changes as it's already handled with the Animated.Value + // - when the event handlers change (rendering doesn't depend on them) + // - when the style props haven't actually change + + return shallowCompare( + { props: this._getPropsForComponentUpdate(this.props), state: this.state }, + this._getPropsForComponentUpdate(nextProps), + nextState + ) || !styleEqual(this.props.style, nextProps.style) + || !styleEqual(this.props.trackStyle, nextProps.trackStyle) + || !styleEqual(this.props.thumbStyle, nextProps.thumbStyle); + }, + render() { + var { + minimumValue, + maximumValue, + minimumTrackTintColor, + maximumTrackTintColor, + thumbTintColor, + styles, + style, + trackStyle, + thumbStyle, + debugTouchArea, + ...other + } = this.props; + var {value, containerSize, trackSize, thumbSize, allMeasured} = this.state; + var mainStyles = styles || defaultStyles; + var thumbLeft = value.interpolate({ + inputRange: [minimumValue, maximumValue], + outputRange: [0, containerSize.width - thumbSize.width], + //extrapolate: 'clamp', + }); + var valueVisibleStyle = {}; + if (!allMeasured) { + valueVisibleStyle.opacity = 0; + } + + var minimumTrackStyle = { + position: 'absolute', + width: Animated.add(thumbLeft, thumbSize.width / 2), + marginTop: -trackSize.height, + backgroundColor: minimumTrackTintColor, + ...valueVisibleStyle + }; + + var touchOverflowStyle = this._getTouchOverflowStyle(); + + return ( + + + + + + {debugTouchArea === true && this._renderDebugThumbTouchRect(thumbLeft)} + + + ); + }, + + _getPropsForComponentUpdate(props) { + var { + value, + onValueChange, + onSlidingStart, + onSlidingComplete, + style, + trackStyle, + thumbStyle, + ...otherProps, + } = props; + + return otherProps; + }, + + _handleStartShouldSetPanResponder: function(e: Object, /*gestureState: Object*/): boolean { + // Should we become active when the user presses down on the thumb? + return this._thumbHitTest(e); + }, + + _handleMoveShouldSetPanResponder: function(/*e: Object, gestureState: Object*/): boolean { + // Should we become active when the user moves a touch over the thumb? + return false; + }, + + _handlePanResponderGrant: function(/*e: Object, gestureState: Object*/) { + this._previousLeft = this._getThumbLeft(this._getCurrentValue()); + this._fireChangeEvent('onSlidingStart'); + }, + _handlePanResponderMove: function(e: Object, gestureState: Object) { + if (this.props.disabled) { + return; + } + + this._setCurrentValue(this._getValue(gestureState)); + this._fireChangeEvent('onValueChange'); + }, + _handlePanResponderRequestEnd: function(e: Object, gestureState: Object) { + // Should we allow another component to take over this pan? + return false; + }, + _handlePanResponderEnd: function(e: Object, gestureState: Object) { + if (this.props.disabled) { + return; + } + + this._setCurrentValue(this._getValue(gestureState)); + this._fireChangeEvent('onSlidingComplete'); + }, + + _measureContainer(x: Object) { + this._handleMeasure('containerSize', x); + }, + + _measureTrack(x: Object) { + this._handleMeasure('trackSize', x); + }, + + _measureThumb(x: Object) { + this._handleMeasure('thumbSize', x); + }, + + _handleMeasure(name: string, x: Object) { + var {width, height} = x.nativeEvent.layout; + var size = {width: width, height: height}; + + var storeName = `_${name}`; + var currentSize = this[storeName]; + if (currentSize && width === currentSize.width && height === currentSize.height) { + return; + } + this[storeName] = size; + + if (this._containerSize && this._trackSize && this._thumbSize) { + this.setState({ + containerSize: this._containerSize, + trackSize: this._trackSize, + thumbSize: this._thumbSize, + allMeasured: true, + }) + } + }, + + _getRatio(value: number) { + return (value - this.props.minimumValue) / (this.props.maximumValue - this.props.minimumValue); + }, + + _getThumbLeft(value: number) { + var ratio = this._getRatio(value); + return ratio * (this.state.containerSize.width - this.state.thumbSize.width); + }, + + _getValue(gestureState: Object) { + var length = this.state.containerSize.width - this.state.thumbSize.width; + var thumbLeft = this._previousLeft + gestureState.dx; + + var ratio = thumbLeft / length; + + if (this.props.step) { + return Math.max(this.props.minimumValue, + Math.min(this.props.maximumValue, + this.props.minimumValue + Math.round(ratio * (this.props.maximumValue - this.props.minimumValue) / this.props.step) * this.props.step + ) + ); + } else { + return Math.max(this.props.minimumValue, + Math.min(this.props.maximumValue, + ratio * (this.props.maximumValue - this.props.minimumValue) + this.props.minimumValue + ) + ); + } + }, + + _getCurrentValue() { + return this.state.value.__getValue(); + }, + + _setCurrentValue(value: number) { + this.state.value.setValue(value); + }, + + _setCurrentValueAnimated(value: number) { + var animationType = this.props.animationType; + var animationConfig = Object.assign( + {}, + DEFAULT_ANIMATION_CONFIGS[animationType], + this.props.animationConfig, + {toValue : value} + ); + + Animated[animationType](this.state.value, animationConfig).start(); + }, + + _fireChangeEvent(event) { + if (this.props[event]) { + this.props[event](this._getCurrentValue()); + } + }, + + _getTouchOverflowSize() { + var state = this.state; + var props = this.props; + + var size = {}; + if (state.allMeasured === true) { + size.width = Math.max(0, props.thumbTouchSize.width - state.thumbSize.width); + size.height = Math.max(0, props.thumbTouchSize.height - state.containerSize.height); + } + + return size; + }, + + _getTouchOverflowStyle() { + var {width, height} = this._getTouchOverflowSize(); + + var touchOverflowStyle = {}; + if (width !== undefined && height !== undefined) { + var verticalMargin = -height / 2; + touchOverflowStyle.marginTop = verticalMargin; + touchOverflowStyle.marginBottom = verticalMargin; + + var horizontalMargin = -width / 2; + touchOverflowStyle.marginLeft = horizontalMargin; + touchOverflowStyle.marginRight = horizontalMargin; + } + + if (this.props.debugTouchArea === true) { + touchOverflowStyle.backgroundColor = 'orange'; + touchOverflowStyle.opacity = 0.5; + } + + return touchOverflowStyle; + }, + + _thumbHitTest(e: Object) { + var nativeEvent = e.nativeEvent; + var thumbTouchRect = this._getThumbTouchRect(); + return thumbTouchRect.containsPoint(nativeEvent.locationX, nativeEvent.locationY); + }, + + _getThumbTouchRect() { + var state = this.state; + var props = this.props; + var touchOverflowSize = this._getTouchOverflowSize(); + + return new Rect( + touchOverflowSize.width / 2 + this._getThumbLeft(this._getCurrentValue()) + (state.thumbSize.width - props.thumbTouchSize.width) / 2, + touchOverflowSize.height / 2 + (state.containerSize.height - props.thumbTouchSize.height) / 2, + props.thumbTouchSize.width, + props.thumbTouchSize.height + ); + }, + + _renderDebugThumbTouchRect(thumbLeft) { + var thumbTouchRect = this._getThumbTouchRect(); + var positionStyle = { + left: thumbLeft, + top: thumbTouchRect.y, + width: thumbTouchRect.width, + height: thumbTouchRect.height, + }; + + return ( + + ); + } +}); + + +var defaultStyles = StyleSheet.create({ + container: { + height: 40, + justifyContent: 'center', + }, + track: { + height: TRACK_SIZE, + borderRadius: TRACK_SIZE / 2, + }, + thumb: { + position: 'absolute', + width: THUMB_SIZE, + height: THUMB_SIZE, + borderRadius: THUMB_SIZE / 2, + }, + touchArea: { + position: 'absolute', + backgroundColor: 'transparent', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + debugThumbTouchArea: { + position: 'absolute', + backgroundColor: 'green', + opacity: 0.5, + } +}); + +module.exports = Slider;