react-native-clock-interval
Version:
React Native Time interval control similar to iOS12 Bedtime picker using ReactART
807 lines (759 loc) • 22.8 kB
JavaScript
/**
* Time Interval Picker.
*/
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { StyleSheet, View, PanResponder, Animated } from 'react-native';
import {
Surface,
Shape,
Path,
LinearGradient,
} from '@react-native-community/art';
import TinyColor from 'tinycolor2';
const styles = StyleSheet.create({
container: {
backgroundColor: 'transparent',
justifyContent: 'center',
alignItems: 'center',
},
dragged: {
backgroundColor: 'transparent',
position: 'absolute',
},
arc: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
},
});
const pointDistance = (x, y) => Math.sqrt(x * x + y * y);
const distanceBetweenPoints = (a, b) =>
Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
const DAY_MINS = 24 * 60;
const timeDistance = (a, b) => {
const aMins = a.hour * 60 + a.minute;
const bMins = b.hour * 60 + b.minute;
return Math.min(
Math.abs(aMins - bMins),
Math.abs(aMins + DAY_MINS - bMins),
Math.abs(aMins - DAY_MINS - bMins),
);
};
const turnTime = ({ hour, minute }, diffMinutes, previous) => {
const result = {};
result.minute = (minute + diffMinutes) % 60;
if (result.minute < 0) {
result.minute += 60;
}
const surplusHours = Math.floor((minute + diffMinutes) / 60);
result.hour = (hour + surplusHours) % 12;
if (result.hour < 0) {
result.hour += 12;
}
if (
timeDistance(result, previous) >
timeDistance({ hour: result.hour + 12, minute: result.minute }, previous)
) {
result.hour += 12;
}
return result;
};
const createPanResponder = (
animatedPositionValue,
getActualPosition,
onRelease,
) =>
PanResponder.create({
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: () => {
animatedPositionValue.setOffset(getActualPosition());
animatedPositionValue.setValue({ x: 0, y: 0 });
},
onPanResponderMove: Animated.event([
null,
{ dx: animatedPositionValue.x, dy: animatedPositionValue.y },
]),
onPanResponderRelease: () => {
onRelease();
},
});
const updateFilter = (handler, parser) => {
let updates = [];
return value => {
updates.push(parser ? parser(value) : value);
requestAnimationFrame(() => {
if (!updates.length) {
return;
}
const val = updates.pop();
updates = [];
handler(val);
});
};
};
/**
* Time Interval Picker component.
* @class TimeInterval
* @extends PureComponent
*/
export default class TimeInterval extends PureComponent {
static propTypes = {
start: PropTypes.shape({
hour: PropTypes.number.isRequired,
minute: PropTypes.number.isRequired,
}).isRequired,
stop: PropTypes.shape({
hour: PropTypes.number.isRequired,
minute: PropTypes.number.isRequired,
}).isRequired,
step: PropTypes.number,
onChange: PropTypes.func,
onRelease: PropTypes.func.isRequired,
componentSize: PropTypes.number.isRequired,
indicatorSize: PropTypes.number.isRequired,
lineWidth: PropTypes.number.isRequired,
lineColor: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]).isRequired,
startIndicator: PropTypes.func.isRequired,
stopIndicator: PropTypes.func.isRequired,
allowLineDrag: PropTypes.bool,
disabled: PropTypes.bool,
};
static defaultProps = {
step: 1,
onChange: null,
allowLineDrag: true,
disabled: false,
};
/**
* Constructor
* @method constructor
* @param {Object} props Component properties
* @constructs Setup
*/
constructor(props) {
super(props);
this.dragPositionToIndicatorPosition = this.dragPositionToIndicatorPosition.bind(
this,
);
this.indicatorPositionToTime = this.indicatorPositionToTime.bind(this);
this.timeToindicatorPosition = this.timeToindicatorPosition.bind(this);
this.positionToLinePosition = this.positionToLinePosition.bind(this);
this.updateArc = this.updateArc.bind(this);
this.updateIndicators = this.updateIndicators.bind(this);
this.reportUpdate = this.reportUpdate.bind(this);
const setTimes = { start: props.start, stop: props.stop };
this.state = {
...setTimes,
setTimes,
startPosition: { x: 0, y: 0 },
stopPosition: { x: 0, y: 0 },
};
this.lastReportedStart = {};
this.lastReportedStop = {};
const dragHandler = (stateTimeValue, setImagePosition) =>
updateFilter(value => {
const imagePos = this.dragPositionToIndicatorPosition(value);
if (!imagePos) {
return;
}
const time = this.indicatorPositionToTime(value);
const enabled = setImagePosition(this.timeToindicatorPosition(time));
if (!enabled) {
return;
}
this.setState(state => {
const previous = state[stateTimeValue];
if (
timeDistance(time, previous) >
timeDistance(
{ hour: time.hour + 12, minute: time.minute },
previous,
)
) {
time.hour += 12;
time.hour %= 24;
}
const result = {};
result[stateTimeValue] = time;
if (this.props.onChange) {
const vals = {
start: state.start,
stop: state.stop,
...result,
};
this.reportUpdate(vals.start, vals.stop);
}
return result;
});
});
this.startDragPosition = new Animated.ValueXY();
this.startImagePosition = new Animated.ValueXY();
this.stopDragPosition = new Animated.ValueXY();
this.stopImagePosition = new Animated.ValueXY();
let startPanEnabled = false;
this.startPanResponder = createPanResponder(
this.startDragPosition,
() => {
startPanEnabled = true;
return this.state.startPosition;
},
() => {
startPanEnabled = false;
this.props.onRelease(this.state.start, this.state.stop);
},
);
this.startDragPosition.addListener(
dragHandler('start', startPosition => {
if (!startPanEnabled) {
return false;
}
this.startImagePosition.setValue(startPosition);
this.setState({ startPosition });
return true;
}),
);
let stopPanEnabled = false;
this.stopPanResponder = createPanResponder(
this.stopDragPosition,
() => {
stopPanEnabled = true;
return this.state.stopPosition;
},
() => {
stopPanEnabled = false;
this.props.onRelease(this.state.start, this.state.stop);
},
);
this.stopDragPosition.addListener(
dragHandler('stop', stopPosition => {
if (!stopPanEnabled) {
return false;
}
this.stopImagePosition.setValue(stopPosition);
this.setState({ stopPosition });
return true;
}),
);
this.turningPanResponder = PanResponder.create({
// filter touch events outside arc line
onStartShouldSetPanResponderCapture: (
{ nativeEvent: { locationX, locationY } },
{ dx, dy },
) => {
const { allowLineDrag, disabled } = this.props;
if (!allowLineDrag || disabled) {
return false;
}
const x = locationX - dx;
const y = locationY - dy;
if (
this.lastTurningCapture &&
this.lastTurningCapture.x === x &&
this.lastTurningCapture.y === y
) {
// drop previously requested
return false;
}
this.lastTurningCapture = { x, y };
// drop touches not in the distance of arc line
const { componentSize, indicatorSize, lineWidth } = this.props;
const componentRadius = componentSize / 2;
const distance = pointDistance(
x - componentRadius,
y - componentRadius,
);
if (
distance > componentRadius - indicatorSize / 2 + lineWidth / 2 ||
distance < componentRadius - indicatorSize / 2 - lineWidth / 2
) {
return false;
}
// accept touches inside active hours
const time = this.indicatorPositionToTime({ x, y });
const startMinutes =
(this.state.start.hour % 12) * 60 + this.state.start.minute;
const stopMinutes =
(this.state.stop.hour % 12) * 60 + this.state.stop.minute;
const minutes = time.hour * 60 + time.minute;
if (
(startMinutes < stopMinutes &&
minutes <= stopMinutes &&
minutes >= startMinutes) ||
(startMinutes > stopMinutes &&
(minutes <= stopMinutes || minutes >= startMinutes))
) {
return true;
}
return false;
},
onPanResponderGrant: ({ nativeEvent: { pageX, pageY } }) => {
const { hour, minute } = this.indicatorPositionToTime(
this.lastTurningCapture,
);
this.turningTimeOffset = {
minutes: hour * 60 + minute,
start: this.state.start,
stop: this.state.stop,
pageX: pageX - this.lastTurningCapture.x,
pageY: pageY - this.lastTurningCapture.y,
};
},
onPanResponderMove: updateFilter(
({ pageX, pageY }) => {
if (!this.turningTimeOffset) {
return;
}
const x = pageX - this.turningTimeOffset.pageX;
const y = pageY - this.turningTimeOffset.pageY;
const { hour, minute } = this.indicatorPositionToTime({ x, y });
const { minutes, start, stop } = this.turningTimeOffset;
const diff = hour * 60 + minute - minutes;
const state = {
start: turnTime(start, diff, this.state.start),
stop: turnTime(stop, diff, this.state.stop),
};
this.updateIndicators(state);
this.setState(state);
},
({ nativeEvent: { pageX, pageY } }) => ({ pageX, pageY }),
),
onPanResponderRelease: () => {
this.lastTurningCapture = null;
this.turningTimeOffset = null;
this.props.onRelease(this.state.start, this.state.stop);
},
});
}
/**
* @returns {undefined}
*/
componentDidMount() {
this.updateIndicators(this.state);
}
/**
* @param {Object} prevProps Old properties
* @returns {undefined}
*/
componentDidUpdate(prevProps) {
const { start, stop } = this.props;
if (
start.hour !== prevProps.start.hour ||
start.minute !== prevProps.start.minute ||
stop.hour !== prevProps.stop.hour ||
stop.minute !== prevProps.stop.minute
) {
this.updateIndicators({ start, stop });
}
}
/**
* Get derived state from properties
* @param {*} nextProps New props
* @param {*} prevState Current state
* @returns {object} new state
*/
static getDerivedStateFromProps({ start, stop }, state) {
if (
start.hour !== state.setTimes.start.hour ||
start.minute !== state.setTimes.start.minute ||
stop.hour !== state.setTimes.stop.hour ||
stop.minute !== state.setTimes.stop.minute
) {
return { start, stop, setTimes: { start, stop } };
}
return null;
}
/**
* @param {Object} dragPosition Relative position of the touch
* @returns {Object} indicator position
*/
dragPositionToIndicatorPosition(dragPosition) {
const { componentSize, indicatorSize } = this.props;
const componentRadius = componentSize / 2;
const indicatorRadius = indicatorSize / 2;
const x = dragPosition.x + indicatorRadius - componentRadius;
const y = dragPosition.y + indicatorRadius - componentRadius;
const ratio = pointDistance(x, y) / (componentRadius - indicatorRadius);
if (ratio > 0.2 && ratio < 2) {
return {
x: x / ratio + componentRadius - indicatorRadius,
y: y / ratio + componentRadius - indicatorRadius,
};
}
return null;
}
/**
* @param {Object} indicatorPosition Relative position of the indicator
* @returns {Object} Hours, minutes on the clock
*/
indicatorPositionToTime(indicatorPosition) {
const { componentSize, indicatorSize, step } = this.props;
const componentRadius = componentSize / 2;
const indicatorRadius = indicatorSize / 2;
const x = indicatorPosition.x + indicatorRadius - componentRadius;
const y = indicatorPosition.y + indicatorRadius - componentRadius;
let hours;
if (!x) {
hours = y > 0 ? 6 : 0;
} else {
hours = (1 + (2 * Math.atan(y / x)) / Math.PI) * 3;
hours = x > 0 ? hours : 6 + hours;
}
const minutes = Math.round((hours * 60) / step) * step;
return { hour: Math.floor(minutes / 60), minute: minutes % 60 };
}
/**
* @param {Object} time Clock time
* @returns {Object} indicator position
*/
timeToindicatorPosition({ hour, minute }) {
const { componentSize, indicatorSize } = this.props;
const radius = componentSize / 2 - indicatorSize / 2;
const time = hour * 60 + minute;
const x = Math.sin((4 * Math.PI * time) / DAY_MINS) * radius;
const y = Math.cos((4 * Math.PI * time) / DAY_MINS) * radius;
return {
x: x + componentSize / 2 - indicatorSize / 2,
y: componentSize / 2 - indicatorSize / 2 - y,
};
}
/**
* @param {Object} position Relative position of the point
* @param {boolean} out Inidicator of outer/inner point
* @returns {Object} line position
*/
positionToLinePosition(position, out = true) {
const { componentSize, indicatorSize, lineWidth } = this.props;
const componentRadius = componentSize / 2;
const x = position.x - componentRadius;
const y = position.y - componentRadius;
const ratio =
pointDistance(x, y) /
(componentRadius -
indicatorSize / 2 +
(out ? lineWidth / 2 : -lineWidth / 2));
return {
x: x / ratio + componentRadius,
y: y / ratio + componentRadius,
};
}
/**
* @returns {Array} ART Path instances
*/
updateArc() {
const { componentSize, indicatorSize, lineWidth, lineColor } = this.props;
const { start, stop, startPosition, stopPosition } = this.state;
const SIDE = {
TOP: {
in: {
x: componentSize / 2,
y: indicatorSize / 2 + lineWidth / 2,
},
out: {
x: componentSize / 2,
y: indicatorSize / 2 - lineWidth / 2,
},
},
RIGHT: {
in: {
x: componentSize - indicatorSize / 2 - lineWidth / 2,
y: componentSize / 2,
},
out: {
x: componentSize - indicatorSize / 2 + lineWidth / 2,
y: componentSize / 2,
},
},
BOTTOM: {
in: {
x: componentSize / 2,
y: componentSize - indicatorSize / 2 - lineWidth / 2,
},
out: {
x: componentSize / 2,
y: componentSize - indicatorSize / 2 + lineWidth / 2,
},
},
LEFT: {
in: {
x: indicatorSize / 2 + lineWidth / 2,
y: componentSize / 2,
},
out: {
x: indicatorSize / 2 - lineWidth / 2,
y: componentSize / 2,
},
},
};
const getNearestSide = ({ hour }) => {
const clockHour = hour % 12;
if (clockHour < 3) {
return SIDE.RIGHT;
}
if (clockHour < 6) {
return SIDE.BOTTOM;
}
if (clockHour < 9) {
return SIDE.LEFT;
}
return SIDE.TOP;
};
const getNextSide = side => {
switch (side) {
case SIDE.TOP:
return SIDE.RIGHT;
case SIDE.RIGHT:
return SIDE.BOTTOM;
case SIDE.BOTTOM:
return SIDE.LEFT;
default:
return SIDE.TOP;
}
};
const sides = [];
{
let side = getNearestSide(start);
if (side === getNearestSide(stop)) {
const startClockMinutes = (start.hour % 12) * 60 + start.minute;
const stopClockMinutes = (stop.hour % 12) * 60 + stop.minute;
if (startClockMinutes === stopClockMinutes) {
return [];
}
if (startClockMinutes > stopClockMinutes) {
for (let i = 0; i < 4; i += 1) {
sides.push(side);
side = getNextSide(side);
}
}
} else {
while (side !== getNearestSide(stop)) {
sides.push(side);
side = getNextSide(side);
}
}
}
const arcStart = {
in: this.positionToLinePosition(
{
x: startPosition.x + indicatorSize / 2,
y: startPosition.y + indicatorSize / 2,
},
false,
),
out: this.positionToLinePosition(
{
x: startPosition.x + indicatorSize / 2,
y: startPosition.y + indicatorSize / 2,
},
true,
),
};
const arcStop = {
in: this.positionToLinePosition(
{
x: stopPosition.x + indicatorSize / 2,
y: stopPosition.y + indicatorSize / 2,
},
false,
),
out: this.positionToLinePosition(
{
x: stopPosition.x + indicatorSize / 2,
y: stopPosition.y + indicatorSize / 2,
},
true,
),
};
const arcRadius = componentSize / 2 - indicatorSize / 2;
if (Array.isArray(lineColor)) {
const startColor = TinyColor(lineColor[0]).toRgb();
const stopColor = TinyColor(lineColor[1]).toRgb();
const getRatioColor = ratio => {
// ratio: 0..1
const getRatio = (a, b) => a + ratio * (b - a);
return TinyColor({
r: getRatio(startColor.r, stopColor.r),
g: getRatio(startColor.g, stopColor.g),
b: getRatio(startColor.b, stopColor.b),
a: getRatio(startColor.a, stopColor.a),
}).toHexString();
};
const points = [arcStart, ...sides, arcStop];
let totalDistance = 0;
for (let i = 1; i < points.length; i += 1) {
totalDistance += distanceBetweenPoints(points[i - 1].in, points[i].in);
}
let elapsedDistance = 0;
const result = [];
for (let i = 1; i < points.length; i += 1) {
const a = points[i - 1];
const b = points[i];
const path = new Path();
path.moveTo(a.in.x, a.in.y);
path
.arcTo(
b.in.x,
b.in.y,
arcRadius - lineWidth / 2,
arcRadius - lineWidth / 2,
)
.lineTo(b.out.x, b.out.y);
path.counterArcTo(
a.out.x,
a.out.y,
arcRadius + lineWidth / 2,
arcRadius + lineWidth / 2,
);
const distance = distanceBetweenPoints(a.in, b.in);
if (distance > 0.1 || (i > 1 && i < points.length - 1)) {
result.push({
id: `gradient-${i}`,
arc: path.close(),
fill: new LinearGradient(
{
'0': getRatioColor(elapsedDistance / totalDistance),
'1': getRatioColor(
(elapsedDistance + distance) / totalDistance,
),
},
`${a.in.x}`,
`${a.in.y}`,
`${b.in.x}`,
`${b.in.y}`,
),
});
}
elapsedDistance += distance;
}
return result;
}
// Single color case
const filteredSides = sides.filter(
s => distanceBetweenPoints(s.in, arcStop.in) > 0.1,
);
const path = new Path();
path.moveTo(arcStart.in.x, arcStart.in.y);
filteredSides.forEach(s =>
path.arcTo(
s.in.x,
s.in.y,
arcRadius - lineWidth / 2,
arcRadius - lineWidth / 2,
),
);
path
.arcTo(
arcStop.in.x,
arcStop.in.y,
arcRadius - lineWidth / 2,
arcRadius - lineWidth / 2,
)
.lineTo(arcStop.out.x, arcStop.out.y);
filteredSides
.reverse()
.forEach(s =>
path.counterArcTo(
s.out.x,
s.out.y,
arcRadius + lineWidth / 2,
arcRadius + lineWidth / 2,
),
);
path.counterArcTo(
arcStart.out.x,
arcStart.out.y,
arcRadius + lineWidth / 2,
arcRadius + lineWidth / 2,
);
return [{ id: 'simple', arc: path.close(), fill: lineColor }];
}
/**
* @returns {undefined}
*/
updateIndicators({ start, stop }) {
const startPosition = this.timeToindicatorPosition(start);
const stopPosition = this.timeToindicatorPosition(stop);
this.startImagePosition.setValue(startPosition);
this.stopImagePosition.setValue(stopPosition);
this.setState({ startPosition, stopPosition });
if (this.props.onChange) {
this.reportUpdate(start, stop);
}
}
reportUpdate(start, stop) {
if (
this.lastReportedStart.hour !== start.hour ||
this.lastReportedStart.minute !== start.minute ||
this.lastReportedStop.hour !== stop.hour ||
this.lastReportedStop.minute !== stop.minute
) {
this.lastReportedStart = start;
this.lastReportedStop = stop;
this.props.onChange(start, stop);
}
}
/**
* Render method.
* @method render
* @returns {string} Markup for the component.
*/
render() {
const {
componentSize,
indicatorSize,
startIndicator,
stopIndicator,
disabled,
} = this.props;
return (
<View
style={[
styles.container,
{
width: componentSize,
height: componentSize,
},
]}
>
<View style={styles.arc} {...this.turningPanResponder.panHandlers}>
<Surface width={componentSize} height={componentSize}>
{this.updateArc().map(({ id, arc, fill }) => (
<Shape key={id} d={arc} fill={fill} />
))}
</Surface>
</View>
<Animated.View
{...(disabled ? {} : this.startPanResponder.panHandlers)}
style={[
this.startImagePosition.getLayout(),
styles.dragged,
{
width: indicatorSize,
height: indicatorSize,
},
]}
>
{startIndicator && startIndicator()}
</Animated.View>
<Animated.View
{...(disabled ? {} : this.stopPanResponder.panHandlers)}
style={[
this.stopImagePosition.getLayout(),
styles.dragged,
{
width: indicatorSize,
height: indicatorSize,
},
]}
>
{stopIndicator && stopIndicator()}
</Animated.View>
</View>
);
}
}