diff --git a/README.md b/README.md index fb7e771a..712db6f0 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Have fun! :metal: - [Progress bar](#progress-bar) - [Spinner](#spinner) - [Sliders](#sliders) + - [Range Slider](#range-slider) - [Textfields](#text-fields) - [Toggles](#toggles) - [Checkbox](#checkbox) @@ -258,10 +259,38 @@ const SliderWithValue = mdl.Slider.slider() 👉 [props reference][slider-props-doc] and [example code][slider-sample] +### Range Slider +![range-slider-demo] + +```jsx + +… +const SliderWithRange = mdl.RangeSlider.slider() + .withStyle(styles.slider) + .withMin(10) + .withMax(100) + .withMinValue(30) + .withMaxValue(50) + .build(); +… + this.setState({ + min: curValue.min, + max: curValue.max, + }) + } + /> +``` + +👉 [props reference][range-slider-props-doc] and [example code][slider-sample] + [mdl-slider]: http://www.getmdl.io/components/index.html#sliders-section [slider-demo]: https://cloud.githubusercontent.com/assets/390805/10123318/6c502e6e-6569-11e5-924a-62c8b850511c.gif +[range-slider-demo]: https://cloud.githubusercontent.com/assets/16245422/12763284/63a2dafc-c9a8-11e5-8fde-37b6f42a60c2.gif [slider-sample]: https://github.com/xinthink/rnmk-demo/blob/master/app/sliders.js [slider-props-doc]: http://www.xinthink.com/react-native-material-kit/docs/lib/mdl/Slider.html#props +[range-slider-props-doc]: http://www.xinthink.com/react-native-material-kit/docs/lib/mdl/RangeSlider.html#props ### Text Fields diff --git a/lib/index.js b/lib/index.js index 61e14e57..3c902fe8 100644 --- a/lib/index.js +++ b/lib/index.js @@ -21,6 +21,7 @@ export { Ripple as MKRipple, Progress as MKProgress, Slider as MKSlider, + RangeSlider as MKRangeSlider, Spinner as MKSpinner, RadioButton as MKRadioButton, Checkbox as MKCheckbox, diff --git a/lib/internal/Thumb.js b/lib/internal/Thumb.js new file mode 100644 index 00000000..fc7f205a --- /dev/null +++ b/lib/internal/Thumb.js @@ -0,0 +1,226 @@ +// +// RangeSlider component. +// +// - [Props](#props) +// - [Defaults](#defaults) +// - [Built-in builders](#builders) +// +// Created by awaidman on 16/1/21. +// + +const React = require('react-native'); +const MKColor = require('../MKColor'); + +const { + Component, + Animated, + View, + PanResponder, + PropTypes, +} = React; + + +// Default color of the upper part of the track +const DEFAULT_UPPER_TRACK_COLOR = '#cccccc'; + +// Color of the thumb when lowest value is chosen +const LOWEST_VALUE_THUMB_COLOR = 'white'; + +// The max scale of the thumb +const THUMB_SCALE_RATIO = 1.3; + +// Width of the thumb border +const THUMB_BORDER_WIDTH = 2; + +// extra spacing enlarging the touchable area +const TRACK_EXTRA_MARGIN_H = 5; + +// ##
Thumb
+// `Thumb` component of the [`Slider`](#Slider). +class Thumb extends Component { + constructor(props) { + super(props); + this.x = 0; // current x-axis position + + this._trackMarginH = (props.radius + THUMB_BORDER_WIDTH) * THUMB_SCALE_RATIO + + TRACK_EXTRA_MARGIN_H; + this._panResponder = {}; + this._animatedLeft = new Animated.Value(0); + this._animatedScale = new Animated.Value(1); + this.state = { + color: LOWEST_VALUE_THUMB_COLOR, + borderColor: DEFAULT_UPPER_TRACK_COLOR, + }; + } + + componentWillMount() { + this._panResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: () => true, + onPanResponderTerminationRequest: () => true, + onShouldBlockNativeResponder: () => true, + + onPanResponderGrant: (evt) => { this.props.onGrant(this, evt); }, + onPanResponderMove: (evt) => { this.props.onMove(this, evt); }, + onPanResponderRelease: (evt) => { this.props.onEnd(this, evt); }, + onPanResponderTerminate: (evt) => { this.props.onEnd(this, evt); }, + }); + + this._onRadiiUpdate(this.props); + this.setState({ + borderColor: this.props.disabledColor, + }); + } + + componentDidMount() { + this._animatedLeft.addListener(this._getOnSliding()); + } + + componentWillReceiveProps(nextProps) { + this._onRadiiUpdate(nextProps); + } + + componentWillUnmount() { + this._animatedLeft.removeAllListeners(); + } + + // when thumb radii updated, re-calc the dimens + _onRadiiUpdate(props) { + this._radii = props.radius; + this._dia = this._radii * 2; + this._containerRadii = this._radii + THUMB_BORDER_WIDTH; + this._containerDia = this._containerRadii * 2; + } + + // return a memoized function to handle sliding animation events + _getOnSliding() { + let prevX = this.x; // memorize the previous x + + // on sliding of the thumb + // `value` - the `left` of the thumb, relative to the container + return ({ value }) => { + // convert to value relative to the track + const x = value + this._containerRadii - this._trackMarginH; + + if (prevX <= 0 && x > 0) { + // leaving the lowest value, scale up the thumb + this._onExplode(); + } else if (prevX > 0 && x <= 0) { + // at lowest value, scale down the thumb + this._onCollapse(); + } + + prevX = x; + }; + } + + // animate the sliding + // `x` - target position, relative to the track + moveTo(x) { + this.x = x; + const x0 = this.x + this._trackMarginH; + + Animated.parallel([ + Animated.timing(this._animatedScale, { + toValue: THUMB_SCALE_RATIO, + duration: 100, + }), + Animated.timing(this._animatedLeft, { + toValue: x0 - this._containerRadii, + duration: 0, + }), + ]).start(); + } + + // stop sliding + confirmMoveTo() { + Animated.timing(this._animatedScale, { + toValue: 1, + duration: 100, + }).start(); + } + + // from 'lowest' to 'non-lowest' + _onExplode() { + this.setState({ + borderColor: this.props.enabledColor, + color: this.props.enabledColor, + }); + } + + // from 'non-lowest' to 'lowest' + _onCollapse() { + this.setState({ + borderColor: this.props.disabledColor, + color: LOWEST_VALUE_THUMB_COLOR, + }); + } + + // Rendering the `Thumb` + render() { + return ( + + + + ); + } +} + +Thumb.propTypes = { + // [RN.View Props](https://facebook.github.io/react-native/docs/view.html#props)... + ...View.propTypes, + + // Callback to handle onPanResponderGrant gesture + onGrant: PropTypes.func, + + // Callback to handle onPanResponderMove gesture + onMove: PropTypes.func, + + // Callback to handle onPanResponderRelease/Terminate gesture + onEnd: PropTypes.func, + + // Color when thumb has no value + disabledColor: PropTypes.string, + + // Color when thumb has value + enabledColor: PropTypes.string, + + // Radius of thumb component + radius: PropTypes.number, +}; + +// ##
Defaults
+Thumb.defaultProps = { + radius: 6, + disabledColor: DEFAULT_UPPER_TRACK_COLOR, +}; + +// ## Public interface +module.exports = Thumb; diff --git a/lib/mdl/RangeSlider.js b/lib/mdl/RangeSlider.js new file mode 100644 index 00000000..acc4a829 --- /dev/null +++ b/lib/mdl/RangeSlider.js @@ -0,0 +1,411 @@ +// +// RangeSlider component. +// +// - [Props](#props) +// - [Defaults](#defaults) +// - [Built-in builders](#builders) +// +// Created by awaidman on 16/1/21. +// + +const React = require('react-native'); +const MKColor = require('../MKColor'); +const { getTheme } = require('../theme'); +const Thumb = require('../internal/Thumb'); + +const { + Component, + Animated, + View, + PropTypes, +} = React; + + +// The max scale of the thumb +const THUMB_SCALE_RATIO = 1.3; + +// Width of the thumb border +const THUMB_BORDER_WIDTH = 2; + +// extra spacing enlarging the touchable area +const TRACK_EXTRA_MARGIN_V = 5; +const TRACK_EXTRA_MARGIN_H = 5; + +// ##
Slider
+class RangeSlider extends Component { + constructor(props) { + super(props); + this.theme = getTheme(); + this._range = { + min: 0, + max: 0, + }; + + this._overriddenThumb = undefined; + this._trackTotalLength = 0; + this._lowerTrackLength = new Animated.Value(this._range.max - this._range.min); + this._lowerTrackMin = new Animated.Value(this._range.min); + } + + componentWillMount() { + this._onThumbRadiiUpdate(this.props); + } + + componentWillReceiveProps(nextProps) { + this._onThumbRadiiUpdate(nextProps); + } + + _onTrackLayout({ nativeEvent: { layout: { width } } }) { + if (this._trackTotalLength !== width) { + this._trackTotalLength = width; + this._setRange({ min: this.props.minValue, max: this.props.maxValue }); + this._updateValue(this._range); + } + } + + // Throw error if preset ranges are invalid + _setRange(range) { + const min2Scale = this._toPixelScale(range.min); + const max2Scale = this._toPixelScale(range.max); + + const minBounds = this._toPixelScale(this.props.min); + const maxBounds = this._toPixelScale(this.props.max); + + if (min2Scale > max2Scale) { + const msg = 'Minimum slider value: ' + range.min + + ' is greater than max value: ' + range.max; + throw msg; + } + if (min2Scale < minBounds || min2Scale > maxBounds) { + const msg = 'Minimum slider value: ' + range.min + + ' exceeds bounds: ' + this.props.min + '-' + this.props.max; + throw msg; + } + if (max2Scale < minBounds || max2Scale > maxBounds) { + const msg = 'Maximum slider value: ' + range.max + + ' exceeds bounds: ' + this.props.min + '-' + this.props.max; + throw msg; + } + + this._range = { + min: min2Scale ? min2Scale : 0, + max: max2Scale ? max2Scale : 0, + }; + + return this._range; + } + + // Scale global xy coordinate values to track values + _toSliderScale(value) { + const trackToRange = (this.props.max - this.props.min) / this._trackTotalLength; + return (value * trackToRange) + this.props.min; + } + + // Scale track values to global xy coordinate system + _toPixelScale(value) { + const rangeToTrack = this._trackTotalLength / (this.props.max - this.props.min); + return (value - this.props.min) * rangeToTrack; + } + + // Set values for thumb components for user touch events + _internalSetValue(ref, value) { + const target = ref === this.refs.minRange ? 'min' : 'max'; + this._range[target] = value; + this._emitChange(); + } + + // Send changed values to onChange callback + _emitChange() { + if (this.props.onChange) { + this.props.onChange({ + min: this._toSliderScale(this._range.min), + max: this._toSliderScale(this._range.max), + }); + } + } + + // Internal update of ranges. Values should be to "Pixel Scale" + _updateValue(values) { + if (!this._trackTotalLength) { + return; + } + + const lthumb = this.refs.minRange; + const rthumb = this.refs.maxRange; + + this._moveThumb(lthumb, values.min); + lthumb.confirmMoveTo(values.min); + + this._moveThumb(rthumb, values.max); + rthumb.confirmMoveTo(values.max); + } + + // Ensure thumbs do not cross each other or track boundaries + _validateMove(dx, trackOriginX, trackWidth, ref) { + const x = dx - trackOriginX; + + const onTrack = (relX) => { + const upperBound = relX >= trackWidth ? trackWidth : relX; + return relX <= 0 ? 0 : upperBound; + }; + + if (ref) { + const lthumb = this.refs.minRange; + const rthumb = this.refs.maxRange; + + let oRef = ref; + if (lthumb.x === rthumb.x) { + if (x > rthumb.x) { + oRef = this._overriddenThumb = rthumb; + ref.confirmMoveTo(ref.x); + } else if (x < lthumb.x) { + oRef = this._overriddenThumb = lthumb; + ref.confirmMoveTo(ref.x); + } + } + + let valX; + if (oRef === lthumb) { + valX = x >= rthumb.x ? rthumb.x : onTrack(x); + } else if (oRef === rthumb) { + valX = x <= lthumb.x ? lthumb.x : onTrack(x); + } + + return { newRef: oRef, x: valX }; + } + } + + // Respond to Grant and Move touch gestures + _updateValueByTouch(ref, evt) { + const ovrRef = this._overriddenThumb ? this._overriddenThumb : ref; + + const dx = evt.nativeEvent.pageX; + this.refs.track.measure((fx, fy, width, height, px) => { + const { newRef, x } = this._validateMove(dx, px, width, ovrRef); + this._internalSetValue(newRef, x); + this._moveThumb(newRef, x); + }); + } + + // Induce smooth animation to move each thumb component + _moveThumb(ref, x) { + ref.moveTo(x); + + Animated.parallel([ + Animated.timing(this._lowerTrackMin, { + toValue: this._range.min, + duration: 0, + }), + Animated.timing(this._lowerTrackLength, { + toValue: this._range.max - this._range.min, + duration: 0, + }), + ]).start(); + } + + // Respond to both cancelled and finished gestures + _endMove(ref, evt) { + const ovrRef = this._overriddenThumb ? this._overriddenThumb : ref; + + const dx = evt.nativeEvent.pageX; + this.refs.track.measure((fx, fy, width, height, px) => { + ovrRef.confirmMoveTo(this._validateMove(dx, px, width)); + this._overriddenThumb = null; + }); + } + + // when thumb radii updated, re-calc the dimens + _onThumbRadiiUpdate(props) { + this._thumbRadii = props.thumbRadius; + this._thumbRadiiWithBorder = this._thumbRadii + THUMB_BORDER_WIDTH; + this._trackMarginV = this._thumbRadiiWithBorder * THUMB_SCALE_RATIO + + TRACK_EXTRA_MARGIN_V - this.props.trackSize / 2; + this._trackMarginH = this._thumbRadiiWithBorder * THUMB_SCALE_RATIO + + TRACK_EXTRA_MARGIN_H; + } + + render() { + // making room for the Thumb, cause's Android doesn't support `overflow: visible` + // - @see http://bit.ly/1Fzr5SE + const trackMargin = { + marginLeft: this._trackMarginH, + marginRight: this._trackMarginH, + marginTop: this._trackMarginV, + marginBottom: this._trackMarginV, + }; + + const sliderStyle = this.theme.sliderStyle; + const lowerTrackColor = this.props.lowerTrackColor || sliderStyle.lowerTrackColor; + const upperTrackColor = this.props.upperTrackColor || sliderStyle.upperTrackColor; + + return ( + + + + + + + + + ); + } +} + +// Public api to update the current ranges +Object.defineProperty(RangeSlider.prototype, 'minValue', { + set: function (minValue) { + const range = this._setRange({ + min: minValue, + max: this._toSliderScale(this._range.max), + }); + this._updateValue(range); + this._emitChange(); + }, + get: function () { + return this._toSliderScale(this._range.min); + }, + enumerable: true, +}); + +Object.defineProperty(RangeSlider.prototype, 'maxValue', { + set: function (maxValue) { + const range = this._setRange({ + min: this._toSliderScale(this._range.min), + max: maxValue, + }); + this._updateValue(range); + this._emitChange(); + }, + get: function () { + return this._toSliderScale(this._range.max); + }, + enumerable: true, +}); + +// ##
Props
+RangeSlider.propTypes = { + // [RN.View Props](https://facebook.github.io/react-native/docs/view.html#props)... + ...View.propTypes, + + // Minimum value of the range, default is `0` + min: PropTypes.number, + + // Maximum value of the range, default is `100` + max: PropTypes.number, + + // Minimum predefined value for left hand thumb + minValue: PropTypes.number, + + // Maximum predefined value for right hand thumb + maxValue: PropTypes.number, + + // The thickness of the RangeSlider track + trackSize: PropTypes.number, + + // Radius of the thumb of the RangeSlider + thumbRadius: PropTypes.number, + + // Color of the lower part of the track, it's also the color of the thumb + lowerTrackColor: PropTypes.string, + + // Color of the upper part of the track + upperTrackColor: PropTypes.string, + + // Callback when value changed + onChange: PropTypes.func, +}; + +// ##
Defaults
+RangeSlider.defaultProps = { + thumbRadius: 6, + trackSize: 2, + min: 0, + max: 100, +}; + + +// -------------------------- +// Builder +// +const { + Builder, +} = require('../builder'); + +// +// ## RangeSlider builder +// +class SliderBuilder extends Builder { + build() { + const BuiltSlider = class extends RangeSlider {}; + BuiltSlider.defaultProps = Object.assign({}, RangeSlider.defaultProps, this.toProps()); + return BuiltSlider; + } +} + +// define builder method for each prop +SliderBuilder.defineProps(RangeSlider.propTypes); + +// ---------- +// ##
Built-in builders
+// +function slider() { + return new SliderBuilder().withBackgroundColor(MKColor.Transparent); +} + + +// ## Public interface +module.exports = RangeSlider; +RangeSlider.Builder = SliderBuilder; +RangeSlider.slider = slider; diff --git a/lib/mdl/Slider.js b/lib/mdl/Slider.js index 29dd858f..5fdbe56a 100644 --- a/lib/mdl/Slider.js +++ b/lib/mdl/Slider.js @@ -12,6 +12,7 @@ const React = require('react-native'); const MKColor = require('../MKColor'); const {getTheme} = require('../theme'); +const Thumb = require('../internal/Thumb'); const { Component, @@ -22,159 +23,17 @@ const { } = React; -// Color of the thumb when lowest value is chosen -const LOWEST_VALUE_THUMB_COLOR = 'white'; - // The max scale of the thumb const THUMB_SCALE_RATIO = 1.3; // Width of the thumb border -const THUMB_BORDER_WIDTH = 2 +const THUMB_BORDER_WIDTH = 2; // extra spacing enlarging the touchable area const TRACK_EXTRA_MARGIN_V = 5; const TRACK_EXTRA_MARGIN_H = 5; -// ##
Thumb
-// `Thumb` component of the [`Slider`](#Slider). -class Thumb extends Component { - constructor(props) { - super(props); - this.x = 0; // current x-axis position - this._animatedLeft = new Animated.Value(0); - this._animatedScale = new Animated.Value(1); - this.state = { - color: LOWEST_VALUE_THUMB_COLOR, - borderColor: '#cccccc', - }; - } - - componentWillMount() { - this._onRadiiUpdate(this.props); - this.setState({ - borderColor: this.props.upperTrackColor, - borderWidth: this.props.trackSize, - }); - } - - componentWillReceiveProps(nextProps) { - this._onRadiiUpdate(nextProps); - } - - componentDidMount() { - this._animatedLeft.addListener(this._getOnSliding()); - } - - componentWillUnmount() { - this._animatedLeft.removeAllListeners(); - } - - // when thumb radii updated, re-calc the dimens - _onRadiiUpdate(props) { - this._radii = props.radius; - this._dia = this._radii * 2; - this._containerRadii = this._radii + THUMB_BORDER_WIDTH; - this._containerDia = this._containerRadii * 2; - } - - // return a memoized function to handle sliding animation events - _getOnSliding() { - let prevX = this.x; // memorize the previous x - - // on sliding of the thumb - // `value` - the `left` of the thumb, relative to the container - return ({value}) => { - // convert to value relative to the track - const x = value + this._containerRadii - this.props.trackMarginH; - - if (prevX <= 0 && x > 0) { - // leaving the lowest value, scale up the thumb - this._onExplode(); - } else if (prevX > 0 && x <= 0) { - // at lowest value, scale down the thumb - this._onCollapse(); - } - - prevX = x; - }; - } - - // animate the sliding - // `x` - target position, relative to the track - moveTo(x) { - this.x = x; - const x0 = this.x + this.props.trackMarginH; - - Animated.parallel([ - Animated.timing(this._animatedScale, { - toValue: THUMB_SCALE_RATIO, - duration: 100, - }), - Animated.timing(this._animatedLeft, { - toValue: x0 - this._containerRadii, - duration: 0, - }), - ]).start(); - } - - // stop sliding - confirmMoveTo() { - Animated.timing(this._animatedScale, { - toValue: 1, - duration: 100, - }).start(); - } - - // from 'lowest' to 'non-lowest' - _onExplode() { - this.setState({ - borderColor: this.props.lowerTrackColor, - color: this.props.lowerTrackColor, - }); - } - - // from 'non-lowest' to 'lowest' - _onCollapse() { - this.setState({ - borderColor: this.props.upperTrackColor, - color: LOWEST_VALUE_THUMB_COLOR, - }); - } - - // Rendering the `Thumb` - render() { - return ( - - - - ); - } -} - - // ##
Slider
class Slider extends Component { constructor(props) { @@ -379,8 +238,8 @@ class Slider extends Component { radius={this.props.thumbRadius} trackSize={this.props.trackSize} trackMarginH={this._trackMarginH} - lowerTrackColor={lowerTrackColor} - upperTrackColor={upperTrackColor} + enabledColor={lowerTrackColor} + disabledColor={upperTrackColor} style={{ top: this._thumbRadiiWithBorder * (THUMB_SCALE_RATIO - 1) + TRACK_EXTRA_MARGIN_V, }} diff --git a/lib/mdl/index.js b/lib/mdl/index.js index 8cd1e2b6..73c2d78e 100644 --- a/lib/mdl/index.js +++ b/lib/mdl/index.js @@ -7,6 +7,7 @@ exports.Textfield = require('./Textfield'); exports.Progress = require('./Progress'); exports.Spinner = require('./Spinner'); exports.Slider = require('./Slider'); +exports.RangeSlider = require('./RangeSlider'); exports.Button = require('./Button'); exports.Ripple = require('./Ripple'); exports.RadioButton = require('./RadioButton');