UNPKG

react-native-draggable-list

Version:

List for React-Native which can be scrolled and/or docked to arbitrarbitrarily many positions on screen. Behaves like the list in Google Maps android app. This functionality cannot be achieved with ListView hence this JS implementation. Performance can su

330 lines (276 loc) 11.8 kB
import React from 'react' import { Animated, Easing, Platform } from 'react-native' // utility for animations. 'easing' and 'callback' are optional const animateTo = (parameterToAnimate, value, duration, easing, callback) => { Animated.timing(parameterToAnimate, { toValue: value, easing: easing || Easing.out(Easing.ease), duration }).start(callback) } const clamp = (value, bottom, top) => Math.min(top, Math.max(value, bottom)) // How far to drag it before it switches between expanded/collapsed states const DRAG_DISTANCE_TO_EXPAND = 1/3 // Higher inertia makes the momentum respond less emphatically to quick 'flick' gestures. // Must be betweem 0 and 1. When 0, momentum is determined solely by the speed of your finger as you release. // At 1, there is no momentum at all. // Between these values, momentum is calculated as a combination of the last value calculated and the current speed of the finge // Maybe INERTIA should be 0, i.e. not exist. Feel free to remove const INERTIA = 2/3 // FRICTION determines how long the momentum will continue const FRICTION = 0.003 // Velocity for automatic animations (px/ms) const DEFAULT_VELOCITY = 0.4 class DraggableList extends React.Component { constructor(props) { super() this.resetToInitalState(props) } resetVariablesToStationary() { // initial and current y-coordinates of gesture this.startTouchY = -1 this.lastTouchY = -1 // `currentInnerTopOffset` at touch start. // Equals -1 * (scroll position at touch start) this.innerTopOffsetAtTouchStart = -1 // `currentOuterTopOffset` at touch start this.outerTopOffsetAtTouchStart = -1 // timestamp to see if a drag is actually a tap this.timeAtTouchStart = -1 // used to calculate velocity: this.timeAtLastPosition = -1 this.velocity = 0 // in dp/ms // whether or not the list was expanded when the gesture began this.positionAtTouchStart = -1 // whether or not an 'event' is taking place this.responderGranted = false // whether or not the gesture has moved this.hasMoved = false } resetToInitalState(props = this.props) { this.resetVariablesToStationary() this.state = ({ // The distance between the top of this component and the top of the parent/screen, // as shown to the user at any point in time. Think of this as the 'master position'. // By default, start collapsed currentOuterTopOffset: new Animated.Value(props.topOffset[props.startPosition]), // The distance between the top of the children and the top of this component. // Note that the scrolling behaviour means this will always be zero or negative. // The value of currentInnerTopOffset is simply -1 times the 'scroll distance' of the // inner `View` relative to the outer `View` of this component. currentInnerTopOffset: new Animated.Value(0), }) } calculateMaxScrollDistance(props) { return props.childrenHeight - props.expandedHeight } // apply smooth transition when list size changes componentWillReceiveProps(nextProps) { if (nextProps.expandedHeight !== this.props.expandedHeight) { const targetLocation = nextProps.topOffset[this.getPosition()] animateTo( this.state.currentOuterTopOffset, targetLocation, Math.abs(targetLocation - this.state.currentOuterTopOffset._value) * 2 / DEFAULT_VELOCITY ) } } getPosition() { for (let i = 0; i < this.props.topOffset.length - 1; i++) { if (this.state.currentOuterTopOffset._value < (this.props.topOffset[i] + this.props.topOffset[i+1]) / 2) { return i } } return this.props.topOffset.length - 1 } responderGrant(event) { this.startTouchY = this.lastTouchY = event.nativeEvent.pageY this.outerTopOffsetAtTouchStart = this.state.currentOuterTopOffset._value this.innerTopOffsetAtTouchStart = this.state.currentInnerTopOffset._value this.timeAtLastPosition = this.timeAtTouchStart = Date.now() this.positionAtTouchStart = this.getPosition() this.props.onTouchStart && this.props.onTouchStart(this.startTouchY - this.innerTopOffsetAtTouchStart - this.outerTopOffsetAtTouchStart) // if there is an ongoing animation, stop it this.setState({ currentOuterTopOffset: new Animated.Value(this.state.currentOuterTopOffset._value), currentInnerTopOffset: new Animated.Value(this.state.currentInnerTopOffset._value), }) this.responderGranted = true } // utility for helping to find position and scrollposition calculateCombinedOffsets(currentTouchY) { return this.outerTopOffsetAtTouchStart + this.innerTopOffsetAtTouchStart + currentTouchY - this.startTouchY } getCurrentOuterTopOffset(currentTouchY) { return clamp( this.calculateCombinedOffsets(currentTouchY), this.props.topOffset[0], this.props.topOffset[this.props.topOffset.length - 1] ) } getCurrentScroll(currentTouchY) { const unboundedScrollPosition = this.props.topOffset[0] - this.calculateCombinedOffsets(currentTouchY) return clamp( unboundedScrollPosition, 0, this.props.childrenHeight - this.props.expandedHeight ) } // utility to check whether the list is expanded at 'newPosition' positionAfterMove(nextYCoord) { const isMovingUpwards = (toPosition) => toPosition < this.positionAtTouchStart const { topOffset } = this.props const fractionBetweenPoints = (ratio, upperIndex) => ratio * topOffset[upperIndex] + (1 - ratio) * topOffset[upperIndex + 1] for (let i = 0; i < this.props.topOffset.length - 1; i++) { if ((isMovingUpwards(i) && nextYCoord < fractionBetweenPoints(DRAG_DISTANCE_TO_EXPAND, i)) || (!isMovingUpwards(i) && nextYCoord < fractionBetweenPoints(1 - DRAG_DISTANCE_TO_EXPAND, i))) { return i } } return this.props.topOffset.length - 1 } // main update method updateComponentOnMove(event) { const currentTouchY = event.nativeEvent.pageY if (Math.abs(currentTouchY - this.startTouchY) > 2) { this.hasMoved = true this.props.onMove && this.props.onMove() } if (this.hasMoved) { // speed of finger const touchVelocity = (currentTouchY - this.lastTouchY) / (Date.now() - this.timeAtLastPosition) // 'momentum' of component this.velocity = INERTIA * this.velocity + (1 - INERTIA) * touchVelocity this.timeAtLastPosition = Date.now() // if it is fully expanded, take this as the new starting state. // Important because the expand/collapse cut-off point is different depending on whether // one is dragging from an expanded or from a collapsed state for (let i = 0; i < this.props.topOffset.length; i++) { const offset = this.props.topOffset[i] if (this.state.currentOuterTopOffset._value <= offset && this.positionAtTouchStart > i || this.state.currentOuterTopOffset._value >= offset && this.positionAtTouchStart < i) { this.positionAtTouchStart = i this.props.onPositionChange && this.props.onPositionChange(i) } } const currentOuterTopOffset = this.getCurrentOuterTopOffset(currentTouchY) const currentInnerTopOffset = -1 * this.getCurrentScroll(currentTouchY) this.lastTouchY = currentTouchY this.setState({ currentOuterTopOffset: new Animated.Value(currentOuterTopOffset), currentInnerTopOffset: new Animated.Value(currentInnerTopOffset), }) } } responderMove(event) { // first check that responderGrant has already been called - if something in the // list has touch responders on it then this may not have occurred. if (this.responderGranted) { // A kind of 'throttle' if (Date.now() - this.timeAtLastPosition >= 16) { this.updateComponentOnMove(event) } } else { this.responderGrant(event) } } getDecelerationTime() { return Math.abs(this.velocity) / FRICTION } getMomentumTravel() { return this.velocity * this.getDecelerationTime() / 2 } updatePosition(index) { if (index !== this.positionAtTouchStart && this.props.onPositionChange) { this.props.onPositionChange(index) } } slideTo(index) { const velocity = this.velocity || DEFAULT_VELOCITY animateTo( this.state.currentOuterTopOffset, this.props.topOffset[index], Math.abs((this.state.currentOuterTopOffset._value - this.props.topOffset[index]) / (2 * velocity)), undefined, () => this.updatePosition(index) ) } scrollTo(innerTopOffset) { const velocity = this.velocity || DEFAULT_VELOCITY animateTo( this.state.currentInnerTopOffset, innerTopOffset, Math.abs((this.state.currentInnerTopOffset._value - innerTopOffset) / (2 * velocity)) ) } scrollAndSlideTo(position, innerTopOffset = 0) { // TODO: This is still an approximation to the correct easing! Perhaps it would be better // to allow currentInnerTopOffset to become positive here, then reset everything once the animation // is complete. That way we would not have to use 'piecewise' easing const velocity = this.velocity || DEFAULT_VELOCITY animateTo( this.state.currentInnerTopOffset, innerTopOffset, Math.abs((this.state.currentInnerTopOffset._value - innerTopOffset) / velocity), Easing.linear, () => this.slideTo(position) ) } slideAndScrollTo(innerTopOffset) { animateTo( this.state.currentOuterTopOffset, this.props.topOffset[0], (this.props.topOffset[0] - this.state.currentOuterTopOffset._value) / this.velocity, innerTopOffset ? Easing.linear : undefined, () => { this.updatePosition(0) this.scrollTo(innerTopOffset) } ) } responderRelease() { this.props.onTouchEnd && this.props.onTouchEnd(this.hasMoved) if (Date.now() - this.timeAtLastPosition > 150) { this.velocity = 0 } const finalTopOffset = this.state.currentInnerTopOffset._value + this.state.currentOuterTopOffset._value + this.getMomentumTravel() const relativeFinalTopOffset = finalTopOffset - this.props.topOffset[0] const finalInnerTopOffset = clamp(relativeFinalTopOffset, -1 * this.calculateMaxScrollDistance(this.props), 0) const remainder = relativeFinalTopOffset - finalInnerTopOffset const newPosition = this.positionAfterMove(remainder + this.props.topOffset[0]) if (this.state.currentInnerTopOffset._value) { this.scrollAndSlideTo(newPosition, finalInnerTopOffset) } else if (finalInnerTopOffset < -100 || (finalInnerTopOffset && finalInnerTopOffset === -1 * this.calculateMaxScrollDistance(this.props))) { this.slideAndScrollTo(finalInnerTopOffset) } else { this.slideTo(newPosition) } // cleanup this.resetVariablesToStationary() } render() { return ( <Animated.View style={{ top: this.state.currentOuterTopOffset, overflow: 'hidden', position: 'absolute', // zIndex is needed for overflow: hidden on android, but breaks shadow on iOS zIndex: Platform.OS === 'ios' ? undefined : 100, ...this.props.style }} onStartShouldSetResponder={() => true} onMoveShouldSetResponder={() => true} onResponderGrant={this.responderGrant.bind(this)} onResponderMove={this.responderMove.bind(this)} onResponderRelease={this.responderRelease.bind(this)}> <Animated.View style={{ top: this.state.currentInnerTopOffset }} removeClippedSubviews={true}> {this.props.children} </Animated.View> </Animated.View> ) } } export default DraggableList