├── LICENSE ├── SimpleViewEditor.js ├── ViewEditor.js ├── index.js ├── package.json └── utilities.js /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Double Qliq, LLC -------------------------------------------------------------------------------- /SimpleViewEditor.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { 3 | Dimensions, 4 | PanResponder, 5 | View, 6 | Animated, 7 | Easing, 8 | StyleSheet, 9 | } from 'react-native'; 10 | import { distance, angle, center } from './utilities'; 11 | const { width, height } = Dimensions.get('window'); 12 | 13 | const styles = StyleSheet.create({ 14 | container: { 15 | overflow: 'hidden', 16 | }, 17 | }); 18 | 19 | export class ViewEditor extends Component { 20 | static propTypes = { 21 | style: View.propTypes.style, 22 | imageHeight: PropTypes.number.isRequired, 23 | imageWidth: PropTypes.number.isRequired, 24 | imageContainerHeight: PropTypes.number, 25 | imageContainerWidth: PropTypes.number, 26 | imageMask: PropTypes.any, 27 | maskHeight: PropTypes.number, 28 | maskWidth: PropTypes.number, 29 | maskPadding: PropTypes.number, 30 | initialOffsetX: PropTypes.number, 31 | initialOffsetY: PropTypes.number, 32 | maxZoomScale: PropTypes.number, 33 | children: PropTypes.any, 34 | rotate: PropTypes.bool, 35 | panning: PropTypes.bool, 36 | center: PropTypes.bool.isRequired, 37 | isLandscape: PropTypes.bool, 38 | isLong: PropTypes.bool, 39 | isWide: PropTypes.bool, 40 | // used for multi-images 41 | bigContainerWidth: PropTypes.number, 42 | // callbacks 43 | onZoomCallback: PropTypes.func, 44 | onSwipeDownCallback: PropTypes.func, 45 | }; 46 | 47 | static defaultProps = { 48 | maskWidth: width, 49 | maskHeight: height, 50 | maskPadding: 0, 51 | imageContainerWidth: width, 52 | imageContainerHeight: height, 53 | initialOffsetX: 0, 54 | initialOffsetY: 0, 55 | maxZoomScale: 1, 56 | center: true, 57 | rotate: false, 58 | panning: true, 59 | }; 60 | 61 | constructor(props, context) { 62 | super(props, context); 63 | const imageDim = (props.isLandscape || props.isLong) && !props.isWide ? props.imageHeight : props.imageWidth; 64 | const containerDim = props.isLong || props.isWide ? props.imageContainerHeight : props.imageContainerWidth; 65 | this.state = { 66 | scale: new Animated.Value(containerDim / imageDim), 67 | pan: new Animated.ValueXY(), 68 | angle: new Animated.Value('0deg'), 69 | animating: false, 70 | render: false, 71 | }; 72 | this._panResponder = {}; 73 | // panning variables 74 | this.panListener = null; 75 | this.currentPanValue = { x: 0, y: 0 }; 76 | this._pan = { x: 0, y: 0 }; 77 | // scaling variables 78 | this.scaleListener = null; 79 | this.currentScaleValue = 1; 80 | this._scale = containerDim / imageDim; 81 | // angle variables 82 | this.angleListener = null; 83 | this.currentAngleValue = 0; 84 | this._angle = 0; 85 | // used for multiTouch 86 | this._previousDistance = 0; 87 | this._previousAngle = 0; 88 | this._previousCenter = 0; 89 | this._multiTouch = false; 90 | // methods 91 | this._handlePanResponderMove = this._handlePanResponderMove.bind(this); 92 | this._handlePanResponderEnd = this._handlePanResponderEnd.bind(this); 93 | this._updatePosition = this._updatePosition.bind(this); 94 | this._updateSize = this._updateSize.bind(this); 95 | this._checkAdjustment = this._checkAdjustment.bind(this); 96 | this._updatePanState = this._updatePanState.bind(this); 97 | // callbacks 98 | this._onZoomCallbackSuccess = false; 99 | this._initialAdjustmentPerformed = false; 100 | } 101 | 102 | componentWillMount() { 103 | this._panResponder = PanResponder.create({ 104 | onStartShouldSetPanResponder: () => !this.state.animating && this.props.panning, 105 | onMoveShouldSetPanResponder: () => !this.state.animating && this.props.panning, 106 | onPanResponderMove: this._handlePanResponderMove, 107 | onPanResponderRelease: this._handlePanResponderEnd, 108 | onPanResponderTerminate: this._handlePanResponderEnd, 109 | }); 110 | } 111 | 112 | componentDidMount() { 113 | this.panListener = this.state.pan.addListener(value => this.currentPanValue = value); 114 | this.scaleListener = this.state.scale.addListener(value => this.currentScaleValue = value); 115 | this.angleListener = this.state.angle.addListener(value => this.currentAngleValue = value); 116 | this._checkAdjustment(); 117 | this.state.pan.setOffset({ x: this.props.initialOffsetX, y: this.props.initialOffsetY }); 118 | } 119 | 120 | componentDidUpdate(prevProps) { 121 | const { 122 | imageHeight, 123 | imageWidth, 124 | imageContainerWidth, 125 | imageContainerHeight, 126 | } = this.props; 127 | const { 128 | imageHeight: prevImageHeight, 129 | imageWidth: prevImageWidth, 130 | imageContainerWidth: prevImageContainerWidth, 131 | imageContainerHeight: prevImageContainerHeight, 132 | } = prevProps; 133 | if ( 134 | imageHeight !== prevImageHeight || 135 | imageWidth !== prevImageWidth || 136 | imageContainerWidth !== prevImageContainerWidth || 137 | imageContainerHeight !== prevImageContainerHeight 138 | ) { 139 | this._checkAdjustment(); 140 | } 141 | } 142 | 143 | componentWillUnmount() { 144 | this.state.pan.removeListener(this.panListener); 145 | this.state.scale.removeListener(this.scaleListener); 146 | this.state.angle.removeListener(this.angleListener); 147 | } 148 | 149 | _updatePosition(x, y) { 150 | this.setState({ animating: true }, () => { 151 | Animated.timing( 152 | this.state.pan, { 153 | toValue: { x, y }, 154 | easing: Easing.elastic(1), 155 | duration: 250 156 | } 157 | ).start(() => this._updatePanState()) 158 | }); 159 | } 160 | 161 | _updateSize(scale) { 162 | this.setState({ animating: true }, () => { 163 | Animated.timing( 164 | this.state.scale, { 165 | toValue: scale, 166 | easing: Easing.elastic(1), 167 | duration: 250 168 | } 169 | ).start(() => { 170 | this.setState({ animating: false }); 171 | this._scale = this.currentScaleValue.value; 172 | }); 173 | }); 174 | } 175 | 176 | _updatePanState(x = this.currentPanValue.x, y = this.currentPanValue.y) { 177 | this.state.pan.setOffset({ x, y }); 178 | this.state.pan.setValue({ x: 0, y: 0 }); 179 | this.setState({ animating: false, render: true }); 180 | } 181 | 182 | _handlePanResponderMove(e, gestureState) { 183 | const { imageContainerWidth, imageWidth, imageHeight } = this.props; 184 | if (gestureState.numberActiveTouches === 1 && !this._multiTouch) { 185 | return Animated.event([ 186 | null, { dx: this.state.pan.x, dy: this.state.pan.y } 187 | ])(e, gestureState); 188 | } else if (gestureState.numberActiveTouches !== 1) { 189 | if (!this._onZoomCallbackSuccess && this.props.onZoomCallback) { 190 | this._onZoomCallbackSuccess = true; 191 | this.props.onZoomCallback(true); 192 | } 193 | this._multiTouch = true; 194 | // set the intial values 195 | this._previousDistance = this._previousDistance === 0 ? 196 | distance(e.nativeEvent.touches) : this._previousDistance; 197 | this._previousAngle = this._previousAngle === 0 ? 198 | angle(e.nativeEvent.touches) : this._previousAngle; 199 | this._previousCenter = this._previousCenter === 0 ? 200 | center(e.nativeEvent.touches) : this._previousCenter; 201 | // angle calculations 202 | const angleChange = angle(e.nativeEvent.touches) - this._previousAngle; 203 | this.state.angle.setValue( 204 | `${parseFloat(this._angle) + angleChange}deg` 205 | ); 206 | // zoom calculations 207 | const currentDistance = distance(e.nativeEvent.touches); 208 | const newScale = ((currentDistance - this._previousDistance + imageContainerWidth) / imageContainerWidth) * this._scale; 209 | this.state.scale.setValue(newScale); 210 | // zoom to the center of the touches 211 | // const currentCenter = center(e.nativeEvent.touches); 212 | // const newWidth = newScale * imageWidth; 213 | // const newHeight = newScale * imageHeight; 214 | // const currentX = this._pan.x > 0 || newWidth < imageWidth ? 215 | // 0 : this._pan.x; 216 | // const currentY = this._pan.y > 0 || newHeight < imageHeight ? 217 | // 0 : this._pan.y; 218 | // console.log('pan', this._pan); 219 | // const x = currentCenter.x - this._previousCenter.x + currentX; 220 | // const y = currentCenter.y - this._previousCenter.y + currentY; 221 | // this.state.pan.setOffset({ x, y }); 222 | // return Animated.event([ 223 | // null, { dx: this.state.pan.x, dy: this.state.pan.y } 224 | // ])(e, gestureState); 225 | } 226 | } 227 | 228 | _handlePanResponderEnd(e) { 229 | const { imageWidth, imageHeight, isLandscape, isLong, isWide, maskWidth, maskHeight, maxZoomScale } = this.props; 230 | const imageDim = (isLandscape || isLong) && !isWide ? imageHeight : imageWidth; 231 | const maskDim = isLong || isWide ? maskHeight : maskWidth; 232 | this._pan = this.currentPanValue; 233 | this._updatePanState(); 234 | if (this._multiTouch) { 235 | this._scale = this.currentScaleValue.value; 236 | this._angle = this.currentAngleValue.value; 237 | this._multiTouch = false; 238 | this._previousDistance = 0; 239 | this._previousAngle = 0; 240 | this._previousCenter = 0; 241 | if (imageDim * this._scale < maskDim) { 242 | if (this.props.onZoomCallback) { 243 | this.props.onZoomCallback(false); 244 | } 245 | this._updateSize(maskDim / imageDim); 246 | } else if (this._scale > maxZoomScale) { 247 | if (this.props.onZoomCallback) { 248 | this.props.onZoomCallback(false); 249 | } 250 | this._updateSize(maxZoomScale); 251 | } else { 252 | if (this.props.onZoomCallback) { 253 | this.props.onZoomCallback(true) 254 | } 255 | } 256 | } 257 | this._checkAdjustment(e); 258 | } 259 | 260 | _checkAdjustment(e) { 261 | const { imageContainerHeight, imageContainerWidth, maskPadding, imageHeight: tempHeight, imageWidth: tempWidth, center, isLandscape } = this.props; 262 | const imageHeight = isLandscape ? tempWidth : tempHeight; 263 | const imageWidth = isLandscape ? tempHeight : tempWidth; 264 | const widthDiff = this._scale * imageWidth - imageContainerWidth; 265 | const heightDiff = this._scale * imageHeight - imageContainerHeight; 266 | const maskPaddingDiffX = widthDiff < 0 && center ? -widthDiff / 2 : maskPadding; 267 | const maskPaddingDiffY = heightDiff < 0 && center ? -heightDiff / 2 : maskPadding; 268 | const positionUpdate = { x: 0, y: 0 }; 269 | const imageLeft = this.currentPanValue.x + widthDiff + maskPaddingDiffX; 270 | const imageAbove = this.currentPanValue.y + heightDiff + maskPaddingDiffY; 271 | const additionalWidth = (tempWidth - this._scale * imageWidth) / 2; 272 | const additionalHeight = (tempHeight - this._scale * imageHeight) / 2; 273 | if (this.currentPanValue.x > maskPaddingDiffX - additionalWidth) { 274 | positionUpdate.x = -this.currentPanValue.x - additionalWidth + maskPaddingDiffX; 275 | } 276 | if (this.currentPanValue.y > maskPaddingDiffY - additionalHeight) { 277 | 278 | positionUpdate.y = -this.currentPanValue.y - additionalHeight + maskPaddingDiffY; 279 | if (!this._initialAdjustmentPerformed) { 280 | this._initialAdjustmentPerformed = true; 281 | } else if (this.props.onSwipeDownCallback) { 282 | this.props.onSwipeDownCallback(positionUpdate, e); 283 | } 284 | } 285 | if (imageAbove < -additionalHeight) { 286 | positionUpdate.y = -imageAbove - additionalHeight; 287 | } 288 | if (imageLeft < -additionalWidth) { 289 | positionUpdate.x = -imageLeft - additionalWidth; 290 | } 291 | this._updatePosition(positionUpdate.x, positionUpdate.y); 292 | } 293 | 294 | render() { 295 | const { pan, scale, render } = this.state; 296 | const { 297 | imageWidth, 298 | imageHeight, 299 | imageContainerWidth, 300 | imageContainerHeight, 301 | imageMask, 302 | children, 303 | rotate, 304 | style, 305 | panning, 306 | } = this.props; 307 | const layout = pan.getLayout(); 308 | const animatedStyle = { 309 | height: imageHeight, 310 | width: imageWidth, 311 | transform: [ 312 | { translateX: layout.left }, 313 | { translateY: layout.top }, 314 | { scale } 315 | ] 316 | }; 317 | if (rotate) { 318 | animatedStyle.transform.push({ rotate: this.state.angle }); 319 | } 320 | return ( 321 | 329 | 332 | {render && children} 333 | 334 | {imageMask} 335 | 336 | ); 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /ViewEditor.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes, cloneElement } from 'react'; 2 | import { 3 | Dimensions, 4 | PanResponder, 5 | View, 6 | Animated, 7 | Easing, 8 | StyleSheet, 9 | ImageEditor, 10 | Image, 11 | } from 'react-native'; 12 | import RNFS from 'react-native-fs'; 13 | import { AnimatedSurface } from 'gl-react-native'; 14 | import { takeSnapshot } from 'react-native-view-shot'; 15 | import { distance, angle, center } from './utilities'; 16 | const { width, height } = Dimensions.get('window'); 17 | 18 | const styles = StyleSheet.create({ 19 | container: { 20 | overflow: 'hidden', 21 | }, 22 | }); 23 | 24 | export class ViewEditor extends Component { 25 | static propTypes = { 26 | style: View.propTypes.style, 27 | imageHeight: PropTypes.number.isRequired, 28 | imageWidth: PropTypes.number.isRequired, 29 | imageContainerHeight: PropTypes.number, 30 | imageContainerWidth: PropTypes.number, 31 | imageMask: PropTypes.any, 32 | maskHeight: PropTypes.number, 33 | maskWidth: PropTypes.number, 34 | maskPadding: PropTypes.number, 35 | children: PropTypes.any, 36 | rotate: PropTypes.bool, 37 | panning: PropTypes.bool, 38 | center: PropTypes.bool.isRequired, 39 | croppingRequired: PropTypes.bool.isRequired, 40 | imageMaskShown: PropTypes.bool.isRequired, 41 | showAtTop: PropTypes.bool, 42 | useCustomContent: PropTypes.bool, 43 | // used for multi-images 44 | bigContainerWidth: PropTypes.number, 45 | bigContainerHeight: PropTypes.number, 46 | requiresMinScale: PropTypes.bool, 47 | initialScale: PropTypes.number, 48 | initialPan: PropTypes.object, 49 | initialRotate: PropTypes.string, 50 | onPressCallback: PropTypes.func, 51 | onLongPressCallback: PropTypes.func, 52 | onLongPressReleaseCallback: PropTypes.func, 53 | onMoveCallback: PropTypes.func, 54 | onEndCallback: PropTypes.func, 55 | onLoad: PropTypes.func, 56 | } 57 | 58 | static defaultProps = { 59 | maskWidth: width, 60 | maskHeight: height, 61 | maskPadding: 0, 62 | imageMaskShown: false, 63 | imageContainerWidth: width, 64 | imageContainerHeight: height, 65 | center: true, 66 | rotate: false, 67 | panning: true, 68 | croppingRequired: false, 69 | requiresMinScale: false, 70 | initialScale: null, 71 | initialPan: null, 72 | showAtTop: false, 73 | useCustomContent: false, 74 | } 75 | 76 | constructor(props, context) { 77 | super(props, context); 78 | const relativeWidth = props.bigContainerWidth || props.imageContainerWidth; 79 | const relativeHeight = props.bigContainerHeight || props.imageContainerHeight; 80 | if (props.requiresMinScale) { 81 | this._minScale = relativeHeight / props.imageHeight < relativeWidth / props.imageWidth ? 82 | relativeWidth / props.imageWidth : 83 | relativeHeight / props.imageHeight; 84 | } else { 85 | this._minScale = relativeHeight / props.imageHeight > relativeWidth / props.imageWidth ? 86 | relativeWidth / props.imageWidth : 87 | relativeHeight / props.imageHeight; 88 | } 89 | this._scale = this._minScale; 90 | this.state = { 91 | scale: new Animated.Value(this._scale), 92 | pan: new Animated.ValueXY(), 93 | angle: new Animated.Value('0deg'), 94 | animating: false, 95 | render: false, 96 | }; 97 | // ref of the surface to capture 98 | this.surface = null; 99 | // ref of view to capture 100 | this.viewRef = null; 101 | // panning variables 102 | this.panListener = null; 103 | this.currentPanValue = { x: 0, y: 0 }; 104 | this._pan = { x: 0, y: 0 }; 105 | // scaling variables 106 | this.scaleListener = null; 107 | this.currentScaleValue = 1; 108 | // angle variables 109 | this.angleListener = null; 110 | this.currentAngleValue = 0; 111 | this._angle = 0; 112 | // used for multiTouch 113 | this._previousDistance = 0; 114 | this._previousAngle = 0; 115 | this._previousCenter = 0; 116 | this._multiTouch = false; 117 | // used for callbacks 118 | this._onPress = null; 119 | this._onLongPress = null; 120 | this._totalMovedX = 0; 121 | this._totalMovedY = 0; 122 | this._onLongPressSuccess = false; 123 | this._onMoveCallbackSucess = false; 124 | 125 | // methods 126 | this._handlePanResponderGrant = this._handlePanResponderGrant.bind(this); 127 | this._handlePanResponderMove = this._handlePanResponderMove.bind(this); 128 | this._handlePanResponderEnd = this._handlePanResponderEnd.bind(this); 129 | this._updatePosition = this._updatePosition.bind(this); 130 | this._updateSize = this._updateSize.bind(this); 131 | this._checkAdjustment = this._checkAdjustment.bind(this); 132 | this._updatePanState = this._updatePanState.bind(this); 133 | this.getScaledDims = this.getScaledDims.bind(this); 134 | this.captureFrameAndCrop = this.captureFrameAndCrop.bind(this); 135 | this.getCurrentState = this.getCurrentState.bind(this); 136 | // the PanResponder 137 | this._panResponder = PanResponder.create({ 138 | onStartShouldSetPanResponder: (e, g) => !this.state.animating && this.props.panning, 139 | onMoveShouldSetPanResponder: (e, g) => !this.state.animating && this.props.panning, 140 | onPanResponderGrant: this._handlePanResponderGrant, 141 | onPanResponderMove: this._handlePanResponderMove, 142 | onPanResponderRelease: this._handlePanResponderEnd, 143 | onPanResponderTerminate: this._handlePanResponderEnd, 144 | }); 145 | } 146 | 147 | componentDidMount() { 148 | const { initialPan, initialScale, croppingRequired, onLoad } = this.props; 149 | this.panListener = this.state.pan.addListener(value => this.currentPanValue = value); 150 | this.scaleListener = this.state.scale.addListener(value => this.currentScaleValue = value); 151 | this.angleListener = this.state.angle.addListener(value => this.currentAngleValue = value); 152 | if (initialScale) { 153 | this._updateSize(initialScale, initialPan); 154 | } else { 155 | this._checkAdjustment(); 156 | } 157 | if (!croppingRequired && typeof onLoad === 'function') { 158 | onLoad(); 159 | } 160 | } 161 | 162 | componentDidUpdate(prevProps) { 163 | const { 164 | imageHeight, 165 | imageWidth, 166 | imageContainerWidth, 167 | imageContainerHeight, 168 | requiresMinScale, 169 | initialRotate 170 | } = this.props; 171 | const { 172 | imageHeight: prevImageHeight, 173 | imageWidth: prevImageWidth, 174 | imageContainerWidth: prevImageContainerWidth, 175 | imageContainerHeight: prevImageContainerHeight, 176 | requiresMinScale: prevRequiresMinScale, 177 | initialRotate: prevInitialRotate, 178 | } = prevProps; 179 | if ( 180 | imageHeight !== prevImageHeight || 181 | imageWidth !== prevImageWidth || 182 | imageContainerWidth !== prevImageContainerWidth || 183 | imageContainerHeight !== prevImageContainerHeight || 184 | initialRotate !== prevInitialRotate 185 | ) { 186 | const relativeWidth = this.props.bigContainerWidth || this.props.imageContainerWidth; 187 | const relativeHeight = this.props.bigContainerHeight || this.props.imageContainerHeight; 188 | if (requiresMinScale) { 189 | this._minScale = relativeHeight / this.props.imageHeight < relativeWidth / this.props.imageWidth ? 190 | relativeWidth / this.props.imageWidth : 191 | relativeHeight / this.props.imageHeight; 192 | this._updateSize(this._minScale, false); 193 | } else { 194 | this._minScale = relativeHeight / this.props.imageHeight > relativeWidth / this.props.imageWidth ? 195 | relativeWidth / this.props.imageWidth : 196 | relativeHeight / this.props.imageHeight; 197 | this._updateSize(this._minScale, false); 198 | } 199 | this._checkAdjustment(this._minScale); 200 | } 201 | } 202 | 203 | componentWillUnmount() { 204 | this.state.pan.removeListener(this.panListener); 205 | this.state.scale.removeListener(this.scaleListener); 206 | this.state.angle.removeListener(this.angleListener); 207 | if (this._onPress) { 208 | clearTimeout(this._onPress); 209 | } 210 | if (this._onLongPress) { 211 | clearTimeout(this._onLongPress); 212 | } 213 | } 214 | 215 | _updatePosition(x, y) { 216 | this.setState({ animating: true }, () => { 217 | Animated.timing( 218 | this.state.pan, { 219 | toValue: { x, y }, 220 | easing: Easing.elastic(1), 221 | duration: 250 222 | } 223 | ).start(() => this._updatePanState()); 224 | }); 225 | } 226 | 227 | _updateSize(scale, initialPan = false) { 228 | this.setState({ animating: true }, () => { 229 | Animated.timing( 230 | this.state.scale, { 231 | toValue: scale, 232 | easing: Easing.elastic(1), 233 | duration: 250 234 | } 235 | ).start(() => { 236 | this.setState({ animating: false }); 237 | this._scale = this.currentScaleValue.value; 238 | if (initialPan) { 239 | const { showAtTop, imageHeight } = this.props; 240 | const pan = Object.assign({}, initialPan); 241 | if (showAtTop) { 242 | const additionalHeight = (imageHeight - this._scale * imageHeight) / 2; 243 | pan.y = -additionalHeight; 244 | } 245 | this._updatePosition(pan.x, pan.y); 246 | } 247 | }); 248 | }); 249 | } 250 | 251 | _updatePanState(x = this.currentPanValue.x, y = this.currentPanValue.y) { 252 | this.state.pan.setOffset({ x, y }); 253 | this.state.pan.setValue({ x: 0, y: 0 }); 254 | this.setState({ animating: false, render: true }); 255 | } 256 | 257 | _handlePanResponderGrant(e, gestureState) { 258 | const { onPressCallback, onLongPressCallback } = this.props; 259 | if (onPressCallback) { 260 | this._onPress = setTimeout(() => { 261 | clearTimeout(this._onPress); 262 | this._onPress = null; 263 | }, 200); 264 | } 265 | if (onLongPressCallback) { 266 | this._onLongPress = setTimeout(() => { 267 | clearTimeout(this._onLongPress); 268 | this._onLongPress = null; 269 | }, 500); 270 | } 271 | } 272 | 273 | _handlePanResponderMove(e, gestureState) { 274 | const { imageContainerWidth, imageWidth, imageHeight, onLongPressCallback, onMoveCallback } = this.props; 275 | if (gestureState.numberActiveTouches === 1 && !this._multiTouch) { 276 | this._totalMovedX += Math.abs(gestureState.dx); 277 | this._totalMovedY += Math.abs(gestureState.dy); 278 | if ( 279 | !this._onLongPress && onLongPressCallback && 280 | (this._totalMovedX < 50 && this._totalMovedY < 50) && 281 | !this._onLongPressSuccess 282 | ) { 283 | this._onLongPressSuccess = true; 284 | return onLongPressCallback(); 285 | } else if (onMoveCallback && !this._onMoveCallbackSucess) { 286 | this._onMoveCallbackSucess = true; 287 | onMoveCallback(); 288 | } 289 | return Animated.event([ 290 | null, { dx: this.state.pan.x, dy: this.state.pan.y } 291 | ])(e, gestureState); 292 | } else if (gestureState.numberActiveTouches !== 1) { 293 | if (onMoveCallback && !this._onMoveCallbackSucess) { 294 | this._onMoveCallbackSucess = true; 295 | onMoveCallback(); 296 | } 297 | this._multiTouch = true; 298 | // set the intial values 299 | this._previousDistance = this._previousDistance === 0 ? 300 | distance(e.nativeEvent.touches) : this._previousDistance; 301 | this._previousAngle = this._previousAngle === 0 ? 302 | angle(e.nativeEvent.touches) : this._previousAngle; 303 | this._previousCenter = this._previousCenter === 0 ? 304 | center(e.nativeEvent.touches) : this._previousCenter; 305 | // angle calculations 306 | const angleChange = angle(e.nativeEvent.touches) - this._previousAngle; 307 | this.state.angle.setValue( 308 | `${parseFloat(this._angle) + angleChange}deg` 309 | ); 310 | // zoom calculations 311 | const currentDistance = distance(e.nativeEvent.touches); 312 | const newScale = ((currentDistance - this._previousDistance + imageContainerWidth) / imageContainerWidth) * this._scale; 313 | this.state.scale.setValue(newScale); 314 | // zoom to the center of the touches 315 | // const currentCenter = center(e.nativeEvent.touches); 316 | // const newWidth = newScale * imageWidth; 317 | // const newHeight = newScale * imageHeight; 318 | // const currentX = this._pan.x > 0 || newWidth < imageWidth ? 319 | // 0 : this._pan.x; 320 | // const currentY = this._pan.y > 0 || newHeight < imageHeight ? 321 | // 0 : this._pan.y; 322 | // console.log('pan', this._pan); 323 | // const x = currentCenter.x - this._previousCenter.x + currentX; 324 | // const y = currentCenter.y - this._previousCenter.y + currentY; 325 | // this.state.pan.setOffset({ x, y }); 326 | // return Animated.event([ 327 | // null, { dx: this.state.pan.x, dy: this.state.pan.y } 328 | // ])(e, gestureState); 329 | } 330 | } 331 | 332 | _handlePanResponderEnd() { 333 | if (this._onPress && ( 334 | (this._totalMovedX < 30 && this._totalMovedY < 30) 335 | )) { 336 | clearTimeout(this._onPress); 337 | this._onPress = null; 338 | this.props.onPressCallback(); 339 | } 340 | if (this._onLongPress) { 341 | clearTimeout(this._onLongPress); 342 | this.onLongPress = null; 343 | } 344 | if (this._onLongPressSuccess) { 345 | this._onLongPressSuccess = false; 346 | if (this.props.onLongPressReleaseCallback) { 347 | this.props.onLongPressReleaseCallback(); 348 | } 349 | } 350 | if (this._onMoveCallbackSucess) { 351 | this._onMoveCallbackSucess = false; 352 | if (this.props.onEndCallback) { 353 | this.props.onEndCallback(); 354 | } 355 | } 356 | this._onMoveCallbackSucess = false; 357 | this._totalMovedX = 0; 358 | this._totalMovedY = 0; 359 | const { imageWidth, imageHeight, imageContainerWidth, imageContainerHeight } = this.props; 360 | this._pan = this.currentPanValue; 361 | this._updatePanState(); 362 | if (this._multiTouch) { 363 | this._scale = this.currentScaleValue.value; 364 | this._angle = this.currentAngleValue.value; 365 | this._multiTouch = false; 366 | this._previousDistance = 0; 367 | this._previousAngle = 0; 368 | this._previousCenter = 0; 369 | const { maskWidth, maskHeight } = this.props; 370 | if (this._minScale > this._scale) { 371 | this._updateSize(this._minScale); 372 | } else if (this._scale > 1) { 373 | this._updateSize(this._scale); 374 | } 375 | } 376 | this._checkAdjustment(); 377 | } 378 | 379 | _checkAdjustment(withScale = this._scale) { 380 | const { imageContainerHeight, imageContainerWidth, maskPadding, imageHeight, imageWidth, center, initialRotate } = this.props; 381 | const widthDiff = withScale * imageWidth - imageContainerWidth; 382 | const heightDiff = withScale * imageHeight - imageContainerHeight; 383 | const maskPaddingDiffX = widthDiff < 0 && center ? -widthDiff / 2 : maskPadding; 384 | const maskPaddingDiffY = heightDiff < 0 && center ? -heightDiff / 2 : maskPadding; 385 | const positionUpdate = { x: 0, y: 0 }; 386 | const imageLeft = this.currentPanValue.x + widthDiff + maskPaddingDiffX; 387 | const imageAbove = this.currentPanValue.y + heightDiff + maskPaddingDiffY; 388 | const additionalWidth = (imageWidth - withScale * imageWidth) / 2; 389 | const additionalHeight = (imageHeight - withScale * imageHeight) / 2; 390 | if (this.currentPanValue.x > maskPaddingDiffX - additionalWidth) { 391 | positionUpdate.x = -this.currentPanValue.x - additionalWidth + maskPaddingDiffX; 392 | } 393 | if (this.currentPanValue.y > maskPaddingDiffY - additionalHeight) { 394 | positionUpdate.y = -this.currentPanValue.y - additionalHeight + maskPaddingDiffY; 395 | } 396 | if (imageAbove < -additionalHeight) { 397 | positionUpdate.y = -imageAbove - additionalHeight; 398 | } 399 | if (imageLeft < -additionalWidth) { 400 | positionUpdate.x = -imageLeft - additionalWidth; 401 | } 402 | this._updatePosition(positionUpdate.x, positionUpdate.y); 403 | } 404 | 405 | getScaledDims() { 406 | return { 407 | top: this._scale * this.props.imageHeight + this.currentPanValue.y, 408 | left: this._scale * this.props.imageWidth + this.currentPanValue.x, 409 | }; 410 | } 411 | 412 | getPanAndScale() { 413 | return { 414 | pan: this.currentPanValue, 415 | scale: this._scale, 416 | }; 417 | } 418 | 419 | captureFrameAndCrop(captureProperties) { 420 | const properties = this.getCurrentState(captureProperties); 421 | const cropImage = (image) => new Promise(resolve => 422 | ImageEditor.cropImage(image, properties, uri => resolve(uri), () => null) 423 | ); 424 | const { croppingRequired, useCustomContent, imageWidth, imageHeight } = this.props; 425 | 426 | const getSize = (url) => new Promise((resolve, reject) => 427 | Image.getSize(url, 428 | (imgWidth, imgHeight) => resolve({ width: imgWidth, height: imgHeight, url }), 429 | (err) => reject(err)) 430 | ); 431 | if (useCustomContent && !croppingRequired) { 432 | return takeSnapshot(this.viewRef, { 433 | quality: 1, 434 | result: 'file', 435 | format: 'jpg', 436 | width: undefined, 437 | height: undefined 438 | }) 439 | .then(url => getSize(url)) 440 | .then(image => { 441 | // because of takeSnapshot resizes image size 442 | properties.size.height *= image.height / imageHeight; 443 | properties.size.width *= image.width / imageWidth; 444 | return cropImage(image.url); 445 | }) 446 | .then(uri => uri) 447 | .catch(err => console.log(err)); 448 | } 449 | 450 | return this.surface.captureFrame({ 451 | quality: 1, 452 | format: 'file', 453 | type: 'jpg', 454 | filePath: `${RNFS.DocumentDirectoryPath}/${new Date().getTime()}.jpg` 455 | }) 456 | .then(image => cropImage(image)) 457 | .then(uri => uri) 458 | .catch(error => console.log(error)); 459 | } 460 | 461 | getCurrentState({ pan, scale, layout, imageLength }) { 462 | const { 463 | imageWidth, 464 | imageHeight, 465 | imageContainerWidth, 466 | imageContainerHeight, 467 | bigContainerWidth, 468 | bigContainerHeight, 469 | initialRotate, 470 | } = this.props; 471 | const containerWidth = bigContainerWidth || imageContainerWidth; 472 | const containerHeight = bigContainerHeight || imageContainerHeight; 473 | const ogScaleX = (containerWidth / imageWidth); 474 | const ogScaleY = (containerHeight / imageHeight); 475 | const scaleChangeX = (scale - ogScaleX) / scale; 476 | const scaleChangeY = (scale - ogScaleY) / scale; 477 | const roundWidth = Math.floor(scale * imageWidth < containerWidth 478 | ? imageWidth 479 | : containerWidth / scale); 480 | const roundHeight = Math.floor(scale * imageHeight < containerHeight 481 | ? imageHeight 482 | : containerHeight / scale); 483 | const ogPanX = (containerWidth - imageWidth) / 2; 484 | const ogPanY = (containerHeight - imageHeight) / 2; 485 | const xZoomOffset = imageWidth * scaleChangeX / 2 - (containerWidth - imageWidth * ogScaleX) < 0 486 | ? 0 487 | : imageWidth * scaleChangeX / 2 - (containerWidth - imageWidth * ogScaleX) / 2; 488 | const yZoomOffset = imageHeight * scaleChangeY / 2 - (containerHeight - imageHeight * ogScaleY) < 0 489 | ? 0 490 | : imageHeight * scaleChangeY / 2 - (containerHeight - imageHeight * ogScaleY) / 2; 491 | const xPanOffset = (ogPanX - pan.x) / scale; 492 | const yPanOffset = (ogPanY - pan.y) / scale; 493 | 494 | // amount image top left corner has moved from zooming 495 | const zoomOffset = { 496 | x: xZoomOffset, 497 | y: yZoomOffset, 498 | }; 499 | 500 | // amount image top left corner has moved from panning 501 | const panOffset = { 502 | x: xPanOffset, 503 | y: yPanOffset 504 | }; 505 | 506 | // total offset of top left corner from original state. 507 | const offset = { 508 | x: zoomOffset.x + panOffset.x, 509 | y: zoomOffset.y + panOffset.y 510 | }; 511 | 512 | return { 513 | offset, 514 | size: { 515 | width: roundWidth, 516 | height: roundHeight, 517 | }, 518 | }; 519 | } 520 | 521 | render() { 522 | const { pan, scale, render } = this.state; 523 | const { 524 | imageWidth, 525 | imageHeight, 526 | imageMask, 527 | children, 528 | rotate, 529 | style, 530 | initialRotate, 531 | croppingRequired, 532 | imageMaskShown, 533 | onLoad, 534 | useCustomContent 535 | } = this.props; 536 | 537 | const layout = pan.getLayout(); 538 | const animatedStyle = { 539 | transform: [ 540 | { translateX: layout.left }, 541 | { translateY: layout.top }, 542 | { scale }, 543 | ] 544 | }; 545 | 546 | if (initialRotate) { 547 | animatedStyle.transform.push({ rotate: initialRotate }); 548 | } else if (rotate) { 549 | animatedStyle.transform.push({ rotate: this.state.angle }); 550 | } 551 | 552 | const wrapStyle = [ 553 | style, 554 | styles.container, 555 | ]; 556 | 557 | if (!render) { 558 | return null; 559 | } 560 | 561 | if (croppingRequired) { 562 | return ( 563 | 564 | this.surface = ref} 566 | width={imageWidth} 567 | height={imageHeight} 568 | style={animatedStyle} 569 | pixelRatio={1} 570 | visibleContent={true} 571 | onLoad={onLoad} 572 | preload={true} 573 | {...this._panResponder.panHandlers} 574 | > 575 | {children} 576 | 577 | {imageMaskShown && imageMask} 578 | 579 | ); 580 | } 581 | if (useCustomContent) { 582 | const { style: contentStyle } = children.props; 583 | return ( 584 | 588 | {cloneElement(children, { 589 | style: [animatedStyle, contentStyle], 590 | ref: (ref) => this.viewRef = ref, 591 | })} 592 | {imageMaskShown && imageMask} 593 | 594 | ); 595 | } 596 | 597 | return ( 598 | 599 | 600 | {children} 601 | 602 | {imageMaskShown && imageMask} 603 | 604 | ); 605 | } 606 | } 607 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { ViewEditor as default } from './ViewEditor'; 2 | export { ViewEditor as SimpleViewEditor } from './SimpleViewEditor'; 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-view-editor-2", 3 | "version": "1.0.0", 4 | "description": "A simple wrapper for being able to rotate, pan, and resize a child view or image with animations", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/doubleqliq/react-native-view-editor.git" 8 | }, 9 | "main": "index.js", 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "keywords": [ 14 | "pannable", 15 | "image" 16 | ], 17 | "author": "DoubleQliq", 18 | "license": "ISC" 19 | } 20 | -------------------------------------------------------------------------------- /utilities.js: -------------------------------------------------------------------------------- 1 | function pow2abs(a, b) { 2 | return Math.pow(Math.abs(a - b), 2); 3 | } 4 | 5 | export function center(touches) { 6 | const a = touches[0]; 7 | const b = touches[1]; 8 | return { 9 | x: (a.pageX + b.pageX) / 2, 10 | y: (a.pageY + b.pageY) / 2, 11 | }; 12 | } 13 | 14 | export function distance(touches) { 15 | const a = touches[0]; 16 | const b = touches[1]; 17 | 18 | return Math.sqrt( 19 | pow2abs(a.pageX, b.pageX) + 20 | pow2abs(a.pageY, b.pageY), 21 | 2); 22 | } 23 | 24 | function toDeg(rad) { 25 | return rad * 180 / Math.PI; 26 | } 27 | 28 | export function angle(touches) { 29 | const a = touches[0]; 30 | const b = touches[1]; 31 | let deg = toDeg(Math.atan2(b.pageY - a.pageY, b.pageX - a.pageX)); 32 | if (deg < 0) { 33 | deg += 360; 34 | } 35 | return deg; 36 | } 37 | --------------------------------------------------------------------------------