UNPKG

@cometchat/chat-uikit-react-native

Version:

Ready-to-use Chat UI Components for React Native

748 lines 30 kB
import React, { Component, createRef } from 'react'; import { Animated, InteractionManager, PanResponder, StyleSheet, View, } from 'react-native'; import { applyPanBoundariesToOffset, calcGestureTouchDistance, calcNewScaledOffsetForZoomCentering, getBoundaryCrossedAnim, getPanMomentumDecayAnim, getZoomToAnimation } from './helper'; const initialState = { originalWidth: null, originalHeight: null, originalPageX: null, originalPageY: null, }; function calcGestureCenterPoint(e, gestureState) { const touches = e?.nativeEvent?.touches; if (!touches[0]) return null; if (gestureState.numberActiveTouches === 2) { if (!touches[1]) return null; return { x: (touches[0].pageX + touches[1].pageX) / 2, y: (touches[0].pageY + touches[1].pageY) / 2, }; } if (gestureState.numberActiveTouches === 1) { return { x: touches[0].pageX, y: touches[0].pageY, }; } return null; } class ReactNativeZoomableView extends Component { zoomSubjectWrapperRef; gestureHandlers; doubleTapFirstTapReleaseTimestamp; static defaultProps = { zoomEnabled: true, panEnabled: true, initialZoom: 1, initialOffsetX: 0, initialOffsetY: 0, maxZoom: 3.5, minZoom: 1, pinchToZoomInSensitivity: 1, pinchToZoomOutSensitivity: 1, movementSensibility: 1, doubleTapDelay: 300, bindToBorders: true, zoomStep: 0.5, onLongPress: null, longPressDuration: 700, contentWidth: undefined, contentHeight: undefined, panBoundaryPadding: 0, disablePanOnInitialZoom: false, }; panAnim = new Animated.ValueXY({ x: 0, y: 0 }); zoomAnim = new Animated.Value(1); __offsets = { x: { value: 0, boundaryCrossedAnimInEffect: false, }, y: { value: 0, boundaryCrossedAnimInEffect: false, }, }; zoomLevel = 1; lastGestureCenterPosition = null; lastGestureTouchDistance; gestureType; gestureStarted = false; /** * Last press time (used to evaluate whether user double tapped) * @type {number} */ longPressTimeout = null; onTransformInvocationInitialized; singleTapTimeoutId; touches = []; doubleTapFirstTap; measureZoomSubjectInterval; constructor(props) { super(props); this.gestureHandlers = PanResponder.create({ onStartShouldSetPanResponder: this._handleStartShouldSetPanResponder, onPanResponderGrant: this._handlePanResponderGrant, onPanResponderMove: this._handlePanResponderMove, onPanResponderRelease: this._handlePanResponderEnd, onPanResponderTerminate: (evt, gestureState) => { // We should also call _handlePanResponderEnd // to properly perform cleanups when the gesture is terminated // (aka gesture handling responsibility is taken over by another component). // This also fixes a weird issue where // on real device, sometimes onPanResponderRelease is not called when you lift 2 fingers up, // but onPanResponderTerminate is called instead for no apparent reason. this._handlePanResponderEnd(evt, gestureState); this.props.onPanResponderTerminate?.(evt, gestureState, this._getZoomableViewEventObject()); }, onPanResponderTerminationRequest: (evt, gestureState) => !!this.props.onPanResponderTerminationRequest?.(evt, gestureState, this._getZoomableViewEventObject()), // Defaults to true to prevent parent components, such as React Navigation's tab view, from taking over as responder. onShouldBlockNativeResponder: (evt, gestureState) => this.props.onShouldBlockNativeResponder?.(evt, gestureState, this._getZoomableViewEventObject()) ?? true, onStartShouldSetPanResponderCapture: (evt, gestureState) => this.props.onStartShouldSetPanResponderCapture?.(evt, gestureState), onMoveShouldSetPanResponderCapture: (evt, gestureState) => this.props.onMoveShouldSetPanResponderCapture?.(evt, gestureState), }); this.zoomSubjectWrapperRef = createRef(); if (this.props.zoomAnimatedValue) this.zoomAnim = this.props.zoomAnimatedValue; if (this.props.panAnimatedValueXY) this.panAnim = this.props.panAnimatedValueXY; this.zoomLevel = props.initialZoom; this.offsetX = props.initialOffsetX; this.offsetY = props.initialOffsetY; this.panAnim.setValue({ x: this.offsetX, y: this.offsetY }); this.zoomAnim.setValue(this.zoomLevel); this.panAnim.addListener(({ x, y }) => { this.offsetX = x; this.offsetY = y; }); this.zoomAnim.addListener(({ value }) => { this.zoomLevel = value; }); this.state = { ...initialState, }; this.lastGestureTouchDistance = 150; this.gestureType = null; } set offsetX(x) { this.__setOffset('x', x); } set offsetY(y) { this.__setOffset('y', y); } get offsetX() { return this.__getOffset('x'); } get offsetY() { return this.__getOffset('y'); } __setOffset(axis, offset) { const offsetState = this.__offsets[axis]; const animValue = this.panAnim?.[axis]; if (this.props.bindToBorders) { const containerSize = axis === 'x' ? this.state?.originalWidth : this.state?.originalHeight; const contentSize = axis === 'x' ? this.props.contentWidth || this.state?.originalWidth : this.props.contentHeight || this.state?.originalHeight; const boundOffset = contentSize && containerSize ? applyPanBoundariesToOffset(offset, containerSize, contentSize, this.zoomLevel, this.props.panBoundaryPadding) : offset; if (animValue && !this.gestureType && !offsetState.boundaryCrossedAnimInEffect) { const boundariesApplied = boundOffset !== offset && boundOffset.toFixed(3) !== offset.toFixed(3); if (boundariesApplied) { offsetState.boundaryCrossedAnimInEffect = true; getBoundaryCrossedAnim(this.panAnim[axis], boundOffset).start(() => { offsetState.boundaryCrossedAnimInEffect = false; }); return; } } } offsetState.value = offset; } __getOffset(axis) { return this.__offsets[axis].value; } componentDidUpdate(prevProps, prevState) { const { zoomEnabled, initialZoom } = this.props; if (prevProps.zoomEnabled && !zoomEnabled) { this.zoomLevel = initialZoom; this.zoomAnim.setValue(this.zoomLevel); } if (!this.onTransformInvocationInitialized && this._invokeOnTransform().successful) { this.panAnim.addListener(() => this._invokeOnTransform()); this.zoomAnim.addListener(() => this._invokeOnTransform()); this.onTransformInvocationInitialized = true; } const currState = this.state; const originalMeasurementsChanged = currState.originalHeight !== prevState.originalHeight || currState.originalWidth !== prevState.originalWidth || currState.originalPageX !== prevState.originalPageX || currState.originalPageY !== prevState.originalPageY; if (this.onTransformInvocationInitialized && originalMeasurementsChanged) { this._invokeOnTransform(); } } componentDidMount() { this.grabZoomSubjectOriginalMeasurements(); // We've already run `grabZoomSubjectOriginalMeasurements` at various events // to make sure the measurements are promptly updated. // However, there might be cases we haven't accounted for, especially when // native processes are involved. To account for those cases, // we'll use an interval here to ensure we're always up-to-date. // The `setState` in `grabZoomSubjectOriginalMeasurements` won't trigger a rerender // if the values given haven't changed, so we're not running performance risk here. this.measureZoomSubjectInterval = setInterval(this.grabZoomSubjectOriginalMeasurements, 1e3); } componentWillUnmount() { clearInterval(this.measureZoomSubjectInterval); } /** * try to invoke onTransform * @private */ _invokeOnTransform() { const zoomableViewEvent = this._getZoomableViewEventObject(); if (!zoomableViewEvent.originalWidth || !zoomableViewEvent.originalHeight) return { successful: false }; this.props.onTransform?.(zoomableViewEvent); return { successful: true }; } /** * Returns additional information about components current state for external event hooks * * @returns {{}} * @private */ _getZoomableViewEventObject(overwriteObj = {}) { return { zoomLevel: this.zoomLevel, offsetX: this.offsetX, offsetY: this.offsetY, originalHeight: this.state.originalHeight, originalWidth: this.state.originalWidth, originalPageX: this.state.originalPageX, originalPageY: this.state.originalPageY, ...overwriteObj, }; } /** * Get the original box dimensions and save them for later use. * (They will be used to calculate boxBorders) * * @private */ grabZoomSubjectOriginalMeasurements = () => { // make sure we measure after animations are complete InteractionManager.runAfterInteractions(() => { // this setTimeout is here to fix a weird issue on iOS where the measurements are all `0` // when navigating back (react-navigation stack) from another view // while closing the keyboard at the same time setTimeout(() => { // In normal conditions, we're supposed to measure zoomSubject instead of its wrapper. // However, our zoomSubject may have been transformed by an initial zoomLevel or offset, // in which case these measurements will not represent the true "original" measurements. // We just need to make sure the zoomSubjectWrapper perfectly aligns with the zoomSubject // (no border, space, or anything between them) const zoomSubjectWrapperRef = this.zoomSubjectWrapperRef; // we don't wanna measure when zoomSubjectWrapperRef is not yet available or has been unmounted zoomSubjectWrapperRef.current?.measureInWindow((x, y, width, height) => { this.setState({ originalWidth: width, originalHeight: height, originalPageX: x, originalPageY: y, }); }); }); }); }; /** * Handles the start of touch events and checks for taps * * @param e * @param gestureState * @returns {boolean} * * @private */ _handleStartShouldSetPanResponder = (e, gestureState) => { if (this.props.onStartShouldSetPanResponder) { this.props.onStartShouldSetPanResponder(e, gestureState, this._getZoomableViewEventObject(), false); } // Always set pan responder on start // of gesture so we can handle tap. // "Pan threshold validation" will be handled // in `onPanResponderMove` instead of in `onMoveShouldSetPanResponder` return true; }; /** * Calculates pinch distance * * @param e * @param gestureState * @private */ _handlePanResponderGrant = (e, gestureState) => { if (this.props.onLongPress) { this.longPressTimeout = setTimeout(() => { this.props.onLongPress?.(e, gestureState, this._getZoomableViewEventObject()); this.longPressTimeout = null; }, this.props.longPressDuration); } this.props.onPanResponderGrant?.(e, gestureState, this._getZoomableViewEventObject()); this.panAnim.stopAnimation(); this.zoomAnim.stopAnimation(); this.gestureStarted = true; }; /** * Handles the end of touch events * * @param e * @param gestureState * * @private */ _handlePanResponderEnd = (e, gestureState) => { if (!this.gestureType) { this._resolveAndHandleTap(e); } this.lastGestureCenterPosition = null; // Trigger final shift animation unless panEnabled is false or disablePanOnInitialZoom is true and we're on the initial zoom level if (this.props.panEnabled && !(this.gestureType === 'shift' && this.props.disablePanOnInitialZoom && this.zoomLevel === this.props.initialZoom)) { getPanMomentumDecayAnim(this.panAnim, { x: gestureState.vx / this.zoomLevel, y: gestureState.vy / this.zoomLevel, }).start(); } if (this.longPressTimeout) { clearTimeout(this.longPressTimeout); this.longPressTimeout = null; } this.props.onPanResponderEnd?.(e, gestureState, this._getZoomableViewEventObject()); if (this.gestureType === 'pinch') { this.props.onZoomEnd?.(e, gestureState, this._getZoomableViewEventObject()); } else if (this.gestureType === 'shift') { this.props.onShiftingEnd?.(e, gestureState, this._getZoomableViewEventObject()); } this.gestureType = null; this.gestureStarted = false; }; /** * Handles the actual movement of our pan responder * * @param e * @param gestureState * * @private */ _handlePanResponderMove = (e, gestureState) => { if (this.props.onPanResponderMove) { if (this.props.onPanResponderMove(e, gestureState, this._getZoomableViewEventObject())) { return false; } } // Only supports 2 touches and below, // any invalid number will cause the gesture to end. if (gestureState.numberActiveTouches <= 2) { if (!this.gestureStarted) { this._handlePanResponderGrant(e, gestureState); } } else { if (this.gestureStarted) { this._handlePanResponderEnd(e, gestureState); } return true; } if (gestureState.numberActiveTouches === 2) { if (this.longPressTimeout) { clearTimeout(this.longPressTimeout); this.longPressTimeout = null; } // change some measurement states when switching gesture to ensure a smooth transition if (this.gestureType !== 'pinch') { this.lastGestureCenterPosition = calcGestureCenterPoint(e, gestureState); this.lastGestureTouchDistance = calcGestureTouchDistance(e, gestureState); } this.gestureType = 'pinch'; this._handlePinching(e, gestureState); } else if (gestureState.numberActiveTouches === 1) { if (this.longPressTimeout && (Math.abs(gestureState.dx) > 5 || Math.abs(gestureState.dy) > 5)) { clearTimeout(this.longPressTimeout); this.longPressTimeout = null; } // change some measurement states when switching gesture to ensure a smooth transition if (this.gestureType !== 'shift') { this.lastGestureCenterPosition = calcGestureCenterPoint(e, gestureState); } const { dx, dy } = gestureState; const isShiftGesture = Math.abs(dx) > 2 || Math.abs(dy) > 2; if (isShiftGesture) { this.gestureType = 'shift'; this._handleShifting(gestureState); } } return false; }; /** * Handles the pinch movement and zooming * * @param e * @param gestureState * * @private */ _handlePinching(e, gestureState) { if (!this.props.zoomEnabled) return; const { maxZoom, minZoom, pinchToZoomInSensitivity, pinchToZoomOutSensitivity, } = this.props; const distance = calcGestureTouchDistance(e, gestureState); if (this.props.onZoomBefore && this.props.onZoomBefore(e, gestureState, this._getZoomableViewEventObject())) { return; } // define the new zoom level and take zoom level sensitivity into consideration const zoomGrowthFromLastGestureState = distance / this.lastGestureTouchDistance; this.lastGestureTouchDistance = distance; const pinchToZoomSensitivity = zoomGrowthFromLastGestureState < 1 ? pinchToZoomOutSensitivity : pinchToZoomInSensitivity; const deltaGrowth = zoomGrowthFromLastGestureState - 1; // 0 - no resistance // 10 - 90% resistance const deltaGrowthAdjustedBySensitivity = deltaGrowth * (1 - (pinchToZoomSensitivity * 9) / 100); let newZoomLevel = this.zoomLevel * (1 + deltaGrowthAdjustedBySensitivity); // make sure max and min zoom levels are respected if (maxZoom !== null && newZoomLevel > maxZoom) { newZoomLevel = maxZoom; } if (newZoomLevel < minZoom) { newZoomLevel = minZoom; } const gestureCenterPoint = calcGestureCenterPoint(e, gestureState); if (!gestureCenterPoint) return; const zoomCenter = { x: gestureCenterPoint.x - this.state.originalPageX, y: gestureCenterPoint.y - this.state.originalPageY, }; const { originalHeight, originalWidth } = this.state; const oldOffsetX = this.offsetX; const oldOffsetY = this.offsetY; const oldScale = this.zoomLevel; const newScale = newZoomLevel; let offsetY = calcNewScaledOffsetForZoomCentering(oldOffsetY, originalHeight, oldScale, newScale, zoomCenter.y); let offsetX = calcNewScaledOffsetForZoomCentering(oldOffsetX, originalWidth, oldScale, newScale, zoomCenter.x); const offsetShift = this._calcOffsetShiftSinceLastGestureState(gestureCenterPoint); if (offsetShift) { offsetX += offsetShift.x; offsetY += offsetShift.y; } this.offsetX = offsetX; this.offsetY = offsetY; this.zoomLevel = newScale; this.panAnim.setValue({ x: this.offsetX, y: this.offsetY }); this.zoomAnim.setValue(this.zoomLevel); this.props.onZoomAfter?.(e, gestureState, this._getZoomableViewEventObject()); } /** * Calculates the amount the offset should shift since the last position during panning * * @param {Vec2D} gestureCenterPoint * * @private */ _calcOffsetShiftSinceLastGestureState(gestureCenterPoint) { const { movementSensibility } = this.props; let shift = null; if (this.lastGestureCenterPosition) { const dx = gestureCenterPoint.x - this.lastGestureCenterPosition.x; const dy = gestureCenterPoint.y - this.lastGestureCenterPosition.y; const shiftX = dx / this.zoomLevel / movementSensibility; const shiftY = dy / this.zoomLevel / movementSensibility; shift = { x: shiftX, y: shiftY, }; } this.lastGestureCenterPosition = gestureCenterPoint; return shift; } /** * Handles movement by tap and move * * @param gestureState * * @private */ _handleShifting(gestureState) { // Skips shifting if panEnabled is false or disablePanOnInitialZoom is true and we're on the initial zoom level if (!this.props.panEnabled || (this.props.disablePanOnInitialZoom && this.zoomLevel === this.props.initialZoom)) { return; } const shift = this._calcOffsetShiftSinceLastGestureState({ x: gestureState.moveX, y: gestureState.moveY, }); if (!shift) return; const offsetX = this.offsetX + shift.x; const offsetY = this.offsetY + shift.y; this._setNewOffsetPosition(offsetX, offsetY); } /** * Set the state to offset moved * * @param {number} newOffsetX * @param {number} newOffsetY * @returns */ async _setNewOffsetPosition(newOffsetX, newOffsetY) { const { onShiftingBefore, onShiftingAfter } = this.props; if (onShiftingBefore?.(null, null, this._getZoomableViewEventObject())) { return; } this.offsetX = newOffsetX; this.offsetY = newOffsetY; this.panAnim.setValue({ x: this.offsetX, y: this.offsetY }); this.zoomAnim.setValue(this.zoomLevel); onShiftingAfter?.(null, null, this._getZoomableViewEventObject()); } /** * Check whether the press event is double tap * or single tap and handle the event accordingly * * @param e * * @private */ _resolveAndHandleTap = (e) => { const now = Date.now(); if (this.doubleTapFirstTapReleaseTimestamp && now - this.doubleTapFirstTapReleaseTimestamp < this.props.doubleTapDelay) { this._addTouch({ ...this.doubleTapFirstTap, id: now.toString(), isSecondTap: true, }); clearTimeout(this.singleTapTimeoutId); delete this.doubleTapFirstTapReleaseTimestamp; delete this.singleTapTimeoutId; delete this.doubleTapFirstTap; this._handleDoubleTap(e); } else { this.doubleTapFirstTapReleaseTimestamp = now; this.doubleTapFirstTap = { id: now.toString(), x: e.nativeEvent.pageX - this.state.originalPageX, y: e.nativeEvent.pageY - this.state.originalPageY, }; this._addTouch(this.doubleTapFirstTap); // persist event so e.nativeEvent is preserved after a timeout delay e.persist(); this.singleTapTimeoutId = setTimeout(() => { delete this.doubleTapFirstTapReleaseTimestamp; delete this.singleTapTimeoutId; this.props.onSingleTap?.(e, this._getZoomableViewEventObject()); }, this.props.doubleTapDelay); } }; _addTouch(touch) { this.touches.push(touch); this.setState({ touches: [...this.touches] }); } _removeTouch(touch) { this.touches.splice(this.touches.indexOf(touch), 1); this.setState({ touches: [...this.touches] }); } /** * Handles the double tap event * * @param e * * @private */ _handleDoubleTap(e) { const { onDoubleTapBefore, onDoubleTapAfter, doubleTapZoomToCenter } = this.props; onDoubleTapBefore?.(e, this._getZoomableViewEventObject()); const nextZoomStep = this._getNextZoomStep(); const { originalPageX, originalPageY } = this.state; // define new zoom position coordinates const zoomPositionCoordinates = { x: e.nativeEvent.pageX - originalPageX, y: e.nativeEvent.pageY - originalPageY, }; // if doubleTapZoomToCenter enabled -> always zoom to center instead if (doubleTapZoomToCenter) { zoomPositionCoordinates.x = 0; zoomPositionCoordinates.y = 0; } this._zoomToLocation(zoomPositionCoordinates.x, zoomPositionCoordinates.y, nextZoomStep).then(() => { onDoubleTapAfter?.(e, this._getZoomableViewEventObject({ zoomLevel: nextZoomStep })); }); } /** * Returns the next zoom step based on current step and zoomStep property. * If we are zoomed all the way in -> return to initialzoom * * @returns {*} */ _getNextZoomStep() { const { zoomStep, maxZoom, initialZoom } = this.props; const { zoomLevel } = this; if (zoomLevel.toFixed(2) === maxZoom.toFixed(2)) { return initialZoom; } const nextZoomStep = zoomLevel * (1 + zoomStep); if (nextZoomStep > maxZoom) { return maxZoom; } return nextZoomStep; } /** * Zooms to a specific location in our view * * @param x * @param y * @param newZoomLevel * * @private */ async _zoomToLocation(x, y, newZoomLevel) { if (!this.props.zoomEnabled) return; this.props.onZoomBefore?.(null, null, this._getZoomableViewEventObject()); // == Perform Zoom Animation == // Calculates panAnim values based on changes in zoomAnim. let prevScale = this.zoomLevel; // Since zoomAnim is calculated in native driver, // it will jitter panAnim once in a while, // because here panAnim is being calculated in js. // However the jittering should mostly occur in simulator. const listenerId = this.zoomAnim.addListener(({ value: newScale }) => { this.panAnim.setValue({ x: calcNewScaledOffsetForZoomCentering(this.offsetX, this.state.originalWidth, prevScale, newScale, x), y: calcNewScaledOffsetForZoomCentering(this.offsetY, this.state.originalHeight, prevScale, newScale, y), }); prevScale = newScale; }); getZoomToAnimation(this.zoomAnim, newZoomLevel).start(() => { this.zoomAnim.removeListener(listenerId); }); // == Zoom Animation Ends == this.props.onZoomAfter?.(null, null, this._getZoomableViewEventObject()); } /** * Zooms to a specificied zoom level. * Returns a promise if everything was updated and a boolean, whether it could be updated or if it exceeded the min/max zoom limits. * * @param {number} newZoomLevel * * @return {Promise<bool>} */ async zoomTo(newZoomLevel) { if ( // if we would go out of our min/max limits -> abort newZoomLevel > this.props.maxZoom || newZoomLevel < this.props.minZoom) return false; await this._zoomToLocation(0, 0, newZoomLevel); return true; } /** * Zooms in or out by a specified change level * Use a positive number for `zoomLevelChange` to zoom in * Use a negative number for `zoomLevelChange` to zoom out * * Returns a promise if everything was updated and a boolean, whether it could be updated or if it exceeded the min/max zoom limits. * * @param {number | null} zoomLevelChange * * @return {Promise<bool>} */ zoomBy(zoomLevelChange = null) { // if no zoom level Change given -> just use zoom step if (!zoomLevelChange) { zoomLevelChange = this.props.zoomStep; } return this.zoomTo(this.zoomLevel + zoomLevelChange); } /** * Moves the zoomed view to a specified position * Returns a promise when finished * * @param {number} newOffsetX the new position we want to move it to (x-axis) * @param {number} newOffsetY the new position we want to move it to (y-axis) * * @return {Promise<bool>} */ moveTo(newOffsetX, newOffsetY) { const { originalWidth, originalHeight } = this.state; const offsetX = (newOffsetX - originalWidth / 2) / this.zoomLevel; const offsetY = (newOffsetY - originalHeight / 2) / this.zoomLevel; return this._setNewOffsetPosition(-offsetX, -offsetY); } /** * Moves the zoomed view by a certain amount. * * Returns a promise when finished * * @param {number} offsetChangeX the amount we want to move the offset by (x-axis) * @param {number} offsetChangeY the amount we want to move the offset by (y-axis) * * @return {Promise<bool>} */ moveBy(offsetChangeX, offsetChangeY) { const offsetX = (this.offsetX * this.zoomLevel - offsetChangeX) / this.zoomLevel; const offsetY = (this.offsetY * this.zoomLevel - offsetChangeY) / this.zoomLevel; return this._setNewOffsetPosition(offsetX, offsetY); } render() { return (<View style={styles.container} {...this.gestureHandlers.panHandlers} ref={this.zoomSubjectWrapperRef} onLayout={this.grabZoomSubjectOriginalMeasurements}> {<Animated.View style={[ styles.zoomSubject, this.props.style, { transform: [ { scale: this.zoomAnim }, ...this.panAnim.getTranslateTransform(), ], }, ]}> {this.props.children} </Animated.View>} </View>); } } const styles = StyleSheet.create({ zoomSubject: { flex: 1, width: '100%', justifyContent: 'center', alignItems: 'center', }, container: { flex: 1, justifyContent: 'center', alignItems: 'center', position: 'relative', overflow: 'hidden', }, }); export default ReactNativeZoomableView; //# sourceMappingURL=ReactNativeZoomableView.js.map