react-native-trimmer
Version:
A Trimmer component that renders in iOS and Android and built entirely in React Native.
574 lines (511 loc) • 20.7 kB
JavaScript
import React from 'react';
import {
StyleSheet,
ScrollView,
Text,
View,
Dimensions,
PanResponder,
Animated,
TouchableHighlight,
} from 'react-native';
import * as Arrow from './Arrow';
const { width: screenWidth, height: screenHeight} = Dimensions.get('window');
const MINIMUM_TRIM_DURATION = 1000;
const MAXIMUM_TRIM_DURATION = 60000;
const MAXIMUM_SCALE_VALUE = 50;
const ZOOM_MULTIPLIER = 5;
const INITIAL_ZOOM = 2;
const SCALE_ON_INIT_TYPE = 'trim-duration'
const SHOW_SCROLL_INDICATOR = true
const CENTER_ON_LAYOUT = true
const TRACK_PADDING_OFFSET = 10;
const HANDLE_WIDTHS = 30;
const MARKER_INCREMENT = 5000;
const SPECIAL_MARKER_INCREMEMNT = 5;
const TRACK_BACKGROUND_COLOR = '#f2f6f5';
const TRACK_BORDER_COLOR = '#c8dad3';
const MARKER_COLOR = '#c8dad3';
const TINT_COLOR = '#93b5b3';
const SCRUBBER_COLOR = '#63707e'
export default class Trimmer extends React.Component {
constructor(props) {
super(props);
let trackScale = props.initialZoomValue || INITIAL_ZOOM
if(props.scaleInOnInit) {
const {
maxTrimDuration = MAXIMUM_TRIM_DURATION,
scaleInOnInitType = SCALE_ON_INIT_TYPE,
trimmerRightHandlePosition,
trimmerLeftHandlePosition
} = this.props;
const isMaxDuration = scaleInOnInitType === 'max-duration';
const trimDuration = isMaxDuration ? maxTrimDuration : (trimmerRightHandlePosition - trimmerLeftHandlePosition);
const smartScaleDivider = isMaxDuration ? 3 : 5; // Based on testing, 3 works better when the goal is to have the entire trimmer fit in the visible area
const percentTrimmed = trimDuration / props.totalDuration;
const smartScaleValue = (2 / percentTrimmed) / smartScaleDivider;
trackScale = this.clamp({ value: smartScaleValue, min: 1, max: props.maximumZoomLevel || MAXIMUM_SCALE_VALUE})
}
this.initiateAnimator();
this.state = {
scrubbing: false, // this value means scrubbing is currently happening
trimming: false, // this value means the handles are being moved
trackScale, // the scale factor for the track
trimmingLeftHandleValue: 0,
trimmingRightHandleValue: 0,
internalScrubbingPosition: 0,
}
}
clamp = ({ value, min, max }) => Math.min(Math.max(value, min), max);
initiateAnimator = () => {
this.scaleTrackValue = new Animated.Value(0);
this.lastDy = 0;
this.trackPanResponder = this.createTrackPanResponder()
this.leftHandlePanResponder = this.createLeftHandlePanResponder()
this.rightHandlePanResponder = this.createRightHandlePanResponder()
this.scrubHandlePanResponder = this.createScrubHandlePanResponder()
}
createScrubHandlePanResponder = () => PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => true,
onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
onMoveShouldSetPanResponder: (evt, gestureState) => true,
onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
onPanResponderGrant: (evt, gestureState) => {
this.setState({
scrubbing: true,
internalScrubbingPosition: this.props.scrubberPosition,
})
this.handleScrubberPressIn()
},
onPanResponderMove: (evt, gestureState) => {
const { trackScale } = this.state;
const {
scrubberPosition,
trimmerLeftHandlePosition,
trimmerRightHandlePosition,
totalDuration,
} = this.props;
const trackWidth = (screenWidth) * trackScale
const calculatedScrubberPosition = (scrubberPosition / totalDuration) * trackWidth;
const newScrubberPosition = ((calculatedScrubberPosition + gestureState.dx) / trackWidth ) * totalDuration
const lowerBound = Math.max(0, trimmerLeftHandlePosition)
const upperBound = trimmerRightHandlePosition
const newBoundedScrubberPosition = this.clamp({
value: newScrubberPosition,
min: lowerBound,
max: upperBound
})
this.setState({ internalScrubbingPosition: newBoundedScrubberPosition })
},
onPanResponderRelease: (evt, gestureState) => {
this.handleScrubbingValueChange(this.state.internalScrubbingPosition)
this.setState({ scrubbing: false })
},
onPanResponderTerminationRequest: (evt, gestureState) => true,
onShouldBlockNativeResponder: (evt, gestureState) => true
})
createRightHandlePanResponder = () => PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => true,
onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
onMoveShouldSetPanResponder: (evt, gestureState) => true,
onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
onPanResponderGrant: (evt, gestureState) => {
this.setState({
trimming: true,
trimmingRightHandleValue: this.props.trimmerRightHandlePosition,
trimmingLeftHandleValue: this.props.trimmerLeftHandlePosition,
})
this.handleRightHandlePressIn()
},
onPanResponderMove: (evt, gestureState) => {
const { trackScale } = this.state;
const {
trimmerRightHandlePosition,
totalDuration,
minimumTrimDuration = MINIMUM_TRIM_DURATION,
maxTrimDuration = MAXIMUM_TRIM_DURATION,
} = this.props;
const trackWidth = screenWidth * trackScale
const calculatedTrimmerRightHandlePosition = (trimmerRightHandlePosition / totalDuration) * trackWidth;
const newTrimmerRightHandlePosition = ((calculatedTrimmerRightHandlePosition + gestureState.dx) / trackWidth ) * totalDuration
const lowerBound = minimumTrimDuration
const upperBound = totalDuration
const newBoundedTrimmerRightHandlePosition = this.clamp({
value: newTrimmerRightHandlePosition,
min: lowerBound,
max: upperBound
})
if (newBoundedTrimmerRightHandlePosition - this.state.trimmingLeftHandleValue >= maxTrimDuration) {
this.setState({
trimmingRightHandleValue: newBoundedTrimmerRightHandlePosition,
trimmingLeftHandleValue: newBoundedTrimmerRightHandlePosition - maxTrimDuration,
})
} else if (newBoundedTrimmerRightHandlePosition - this.state.trimmingLeftHandleValue <= minimumTrimDuration) {
this.setState({
trimmingRightHandleValue: newBoundedTrimmerRightHandlePosition,
trimmingLeftHandleValue: newBoundedTrimmerRightHandlePosition - minimumTrimDuration,
})
} else {
this.setState({ trimmingRightHandleValue: newBoundedTrimmerRightHandlePosition })
}
},
onPanResponderRelease: (evt, gestureState) => {
this.handleHandleSizeChange()
this.setState({ trimming: false })
},
onPanResponderTerminationRequest: (evt, gestureState) => true,
onShouldBlockNativeResponder: (evt, gestureState) => true
})
createLeftHandlePanResponder = () => PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => true,
onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
onMoveShouldSetPanResponder: (evt, gestureState) => true,
onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
onPanResponderGrant: (evt, gestureState) => {
this.setState({
trimming: true,
trimmingRightHandleValue: this.props.trimmerRightHandlePosition,
trimmingLeftHandleValue: this.props.trimmerLeftHandlePosition,
})
this.handleLeftHandlePressIn()
},
onPanResponderMove: (evt, gestureState) => {
const { trackScale } = this.state;
const {
trimmerLeftHandlePosition,
trimmerRightHandlePosition,
totalDuration,
minimumTrimDuration = MINIMUM_TRIM_DURATION,
maxTrimDuration = MAXIMUM_TRIM_DURATION,
} = this.props;
const trackWidth = (screenWidth) * trackScale
const calculatedTrimmerLeftHandlePosition = (trimmerLeftHandlePosition / totalDuration) * trackWidth;
const newTrimmerLeftHandlePosition = ((calculatedTrimmerLeftHandlePosition + gestureState.dx) / trackWidth ) * totalDuration
const lowerBound = 0
const upperBound = totalDuration - minimumTrimDuration
const newBoundedTrimmerLeftHandlePosition = this.clamp({
value: newTrimmerLeftHandlePosition,
min: lowerBound,
max: upperBound
})
if (this.state.trimmingRightHandleValue - newBoundedTrimmerLeftHandlePosition >= maxTrimDuration) {
this.setState({
trimmingRightHandleValue: newBoundedTrimmerLeftHandlePosition + maxTrimDuration,
trimmingLeftHandleValue: newBoundedTrimmerLeftHandlePosition,
})
} else if (this.state.trimmingRightHandleValue - newBoundedTrimmerLeftHandlePosition <= minimumTrimDuration) {
this.setState({
trimmingRightHandleValue: newBoundedTrimmerLeftHandlePosition,
trimmingLeftHandleValue: newBoundedTrimmerLeftHandlePosition - minimumTrimDuration,
})
} else {
this.setState({ trimmingLeftHandleValue: newBoundedTrimmerLeftHandlePosition })
}
},
onPanResponderRelease: (evt, gestureState) => {
this.handleHandleSizeChange()
this.setState({ trimming: false })
},
onPanResponderTerminationRequest: (evt, gestureState) => true,
onShouldBlockNativeResponder: (evt, gestureState) => true
})
calculatePinchDistance = (x1, y1, x2, y2) => {
let dx = Math.abs(x1 - x2)
let dy = Math.abs(y1 - y2)
const distance = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
return distance
}
createTrackPanResponder = () => PanResponder.create({
// Ask to be the responder:
onStartShouldSetPanResponder: (evt, gestureState) => !this.state.scrubbing && !this.state.trimming,
onStartShouldSetPanResponderCapture: (evt, gestureState) => !this.state.scrubbing && !this.state.trimming,
onMoveShouldSetPanResponder: (evt, gestureState) => !this.state.scrubbing && !this.state.trimming,
onMoveShouldSetPanResponderCapture: (evt, gestureState) => !this.state.scrubbing && !this.state.trimming,
onPanResponderGrant: (evt, gestureState) => {
this.lastScaleDy = 0;
const touches = evt.nativeEvent.touches || {};
if (touches.length == 2) {
const pinchDistance = this.calculatePinchDistance(touches[0].pageX, touches[0].pageY, touches[1].pageX, touches[1].pageY);
this.lastScalePinchDist = pinchDistance;
}
},
onPanResponderMove: (evt, gestureState) => {
const touches = evt.nativeEvent.touches;
const { maximumZoomLevel = MAXIMUM_SCALE_VALUE, zoomMultiplier = ZOOM_MULTIPLIER } = this.props;
if (touches.length == 2) {
const pinchDistance = this.calculatePinchDistance(touches[0].pageX, touches[0].pageY, touches[1].pageX, touches[1].pageY);
if(this.lastScalePinchDist === undefined) {
this.lastScalePinchDist = pinchDistance
}
const stepValue = pinchDistance - this.lastScalePinchDist;
this.lastScalePinchDist = pinchDistance
const scaleStep = (stepValue * zoomMultiplier) / screenHeight
const { trackScale } = this.state;
const newTrackScaleValue = trackScale + scaleStep;
const newBoundedTrackScaleValue = Math.max(Math.min(newTrackScaleValue, maximumZoomLevel), 1)
this.setState({trackScale: newBoundedTrackScaleValue})
} else {
const stepValue = (gestureState.dy - this.lastScaleDy);
this.lastScaleDy = gestureState.dy
const scaleStep = (stepValue * zoomMultiplier) / screenHeight
const { trackScale } = this.state;
const newTrackScaleValue = trackScale + scaleStep;
const newBoundedTrackScaleValue = Math.max(Math.min(newTrackScaleValue, maximumZoomLevel), 1)
this.setState({trackScale: newBoundedTrackScaleValue})
}
},
onPanResponderTerminationRequest: (evt, gestureState) => true,
onShouldBlockNativeResponder: (evt, gestureState) => true
})
handleScrubbingValueChange = (newScrubPosition) => {
const { onScrubbingComplete } = this.props;
onScrubbingComplete && onScrubbingComplete(newScrubPosition | 0)
}
handleHandleSizeChange = () => {
const { onHandleChange } = this.props;
const { trimmingLeftHandleValue, trimmingRightHandleValue } = this.state;
onHandleChange && onHandleChange({
leftPosition: trimmingLeftHandleValue | 0,
rightPosition: trimmingRightHandleValue | 0,
});
}
handleLeftHandlePressIn = () => {
const { onLeftHandlePressIn } = this.props;
onLeftHandlePressIn && onLeftHandlePressIn()
}
handleRightHandlePressIn = () => {
const { onRightHandlePressIn } = this.props;
onRightHandlePressIn && onRightHandlePressIn()
}
handleScrubberPressIn = () => {
const { onScrubberPressIn } = this.props;
onScrubberPressIn && onScrubberPressIn()
}
render() {
const {
maxTrimDuration,
minimumTrimDuration,
totalDuration,
trimmerLeftHandlePosition,
trimmerRightHandlePosition,
scrubberPosition,
trackBackgroundColor = TRACK_BACKGROUND_COLOR,
trackBorderColor = TRACK_BORDER_COLOR,
markerColor = MARKER_COLOR,
tintColor = TINT_COLOR,
scrubberColor = SCRUBBER_COLOR,
centerOnLayout = CENTER_ON_LAYOUT,
showScrollIndicator = SHOW_SCROLL_INDICATOR,
} = this.props;
// if(maxTrimDuration < trimmerRightHandlePosition - trimmerLeftHandlePosition) {
// console.error('maxTrimDuration is less than trimRightHandlePosition minus trimmerLeftHandlePosition', {
// minimumTrimDuration, trimmerRightHandlePosition, trimmerLeftHandlePosition
// })
// return null
// }
if(minimumTrimDuration > trimmerRightHandlePosition - trimmerLeftHandlePosition) {
console.error('minimumTrimDuration is less than trimRightHandlePosition minus trimmerLeftHandlePosition', {
minimumTrimDuration, trimmerRightHandlePosition, trimmerLeftHandlePosition
})
return null
}
const {
trimming,
scrubbing,
internalScrubbingPosition,
trackScale,
trimmingLeftHandleValue,
trimmingRightHandleValue
} = this.state;
const trackWidth = screenWidth * trackScale
if(isNaN(trackWidth)) {
console.log('ERROR render() trackWidth !== number. screenWidth', screenWidth, ', trackScale', trackScale, ', ', trackWidth)
}
const trackBackgroundStyles = [
styles.trackBackground,
{ width: trackWidth, backgroundColor: trackBackgroundColor, borderColor: trackBorderColor
}];
const leftPosition = trimming ? trimmingLeftHandleValue : trimmerLeftHandlePosition
const rightPosition = trimming ? trimmingRightHandleValue : trimmerRightHandlePosition
const scrubPosition = scrubbing ? internalScrubbingPosition : scrubberPosition
const boundedLeftPosition = Math.max(leftPosition, 0)
const boundedScrubPosition = this.clamp({ value: scrubPosition, min: boundedLeftPosition, max: rightPosition })
const boundedTrimTime = Math.max(rightPosition - boundedLeftPosition, 0)
const actualTrimmerWidth = (boundedTrimTime / totalDuration) * trackWidth;
const actualTrimmerOffset = ((boundedLeftPosition / totalDuration) * trackWidth) + TRACK_PADDING_OFFSET + HANDLE_WIDTHS;
const actualScrubPosition = ((boundedScrubPosition / totalDuration) * trackWidth) + TRACK_PADDING_OFFSET + HANDLE_WIDTHS;
const onLayoutHandler = centerOnLayout
? {
onLayout: () => {
const centerOffset = actualTrimmerOffset + (actualTrimmerWidth / 2) - (screenWidth / 2);
this.scrollView.scrollTo({x: centerOffset, y: 0, animated: false});
}
}
: null
if(isNaN(actualTrimmerWidth)) {
console.log('ERROR render() actualTrimmerWidth !== number. boundedTrimTime', boundedTrimTime, ', totalDuration', totalDuration, ', trackWidth', trackWidth)
}
const markers = new Array((totalDuration / MARKER_INCREMENT) | 0).fill(0) || [];
return (
<View style={styles.root}>
<ScrollView
ref={scrollView => this.scrollView = scrollView}
scrollEnabled={!trimming && !scrubbing}
style={[
styles.horizontalScrollView,
{ transform: [{ scaleX: 1.0 }] },
]}
horizontal
showsHorizontalScrollIndicator={showScrollIndicator}
{...{...this.trackPanResponder.panHandlers, ...onLayoutHandler}}
>
<View style={trackBackgroundStyles}>
<View style={styles.markersContainer}>
{
markers.map((m,i) => (
<View
key={`marker-${i}`}
style={[
styles.marker,
i % SPECIAL_MARKER_INCREMEMNT ? {} : styles.specialMarker,
i === 0 || i === markers.length - 1 ? styles.hiddenMarker : {},
{ backgroundColor: markerColor }
]}/>
))
}
</View>
</View>
{
typeof scrubberPosition === 'number'
? (
<View style={[
styles.scrubberContainer,
{ left: actualScrubPosition },
]}
hitSlop={{top: 8, bottom: 8, right: 8, left: 8}}
{...this.scrubHandlePanResponder.panHandlers}
>
<View
style={[styles.scrubberHead, { backgroundColor: scrubberColor }]}
/>
<View style={[styles.scrubberTail, { backgroundColor: scrubberColor }]} />
</View>
)
: null
}
<View {...this.leftHandlePanResponder.panHandlers} style={[
styles.handle,
styles.leftHandle,
{ backgroundColor: tintColor, left: actualTrimmerOffset - HANDLE_WIDTHS }
]}>
<Arrow.Left />
</View>
<View style={[
styles.trimmer,
{ width: actualTrimmerWidth, left: actualTrimmerOffset },
{ borderColor: tintColor }
]}>
<View style={[styles.selection, { backgroundColor: tintColor }]}/>
</View>
<View {...this.rightHandlePanResponder.panHandlers} style={[
styles.handle,
styles.rightHandle,
{ backgroundColor: tintColor, left: actualTrimmerOffset + actualTrimmerWidth }
]} >
<Arrow.Right />
</View>
</ScrollView>
</View>
);
}
}
const styles = StyleSheet.create({
root: {
height: 140,
},
horizontalScrollView: {
height: 140,
overflow: 'hidden',
position: 'relative',
},
trackBackground: {
overflow: 'hidden',
marginVertical: 20,
backgroundColor: TRACK_BACKGROUND_COLOR,
borderRadius: 5,
borderWidth: 1,
borderColor: TRACK_BORDER_COLOR,
height: 100,
marginHorizontal: HANDLE_WIDTHS + TRACK_PADDING_OFFSET,
},
trimmer: {
position: 'absolute',
left: TRACK_PADDING_OFFSET,
top: 17,
borderColor: TINT_COLOR,
borderWidth: 3,
height: 106,
},
handle: {
position: 'absolute',
width: HANDLE_WIDTHS,
height: 106,
backgroundColor: TINT_COLOR,
top: 17,
},
leftHandle: {
borderTopLeftRadius: 10,
borderBottomLeftRadius: 10,
},
rightHandle: {
borderTopRightRadius: 10,
borderBottomRightRadius: 10,
},
selection: {
opacity: 0.2,
backgroundColor: TINT_COLOR,
width: '100%',
height: '100%',
},
markersContainer: {
flexDirection: 'row',
width: '100%',
height: '100%',
justifyContent: 'space-between',
alignItems: 'center',
},
marker: {
backgroundColor: MARKER_COLOR, // marker color,
width: 2,
height: 8,
borderRadius: 2,
},
specialMarker: {
height: 22,
},
hiddenMarker: {
opacity: 0
},
scrubberContainer: {
zIndex: 1,
position: 'absolute',
width: 14,
height: "100%",
// justifyContent: 'center',
alignItems: 'center',
},
scrubberHead: {
position: 'absolute',
backgroundColor: SCRUBBER_COLOR,
width: 14,
height: 14,
borderRadius: 14,
},
scrubberTail: {
backgroundColor: SCRUBBER_COLOR,
height: 123,
width: 3,
borderBottomLeftRadius: 3,
borderBottomRightRadius: 3,
},
});