├── PulsingCircle.js ├── README.md ├── index.js └── package.json /PulsingCircle.js: -------------------------------------------------------------------------------- 1 | /* eslint react-native/no-color-literals:0 */ 2 | import React, { Component } from 'react' 3 | import { 4 | Animated, 5 | StyleSheet, 6 | View } from 'react-native' 7 | 8 | const PULSING_INTERVAL = 500 9 | const CIRCLE_RADIUS = 10 10 | 11 | const styles = StyleSheet.create({ 12 | circle: { 13 | height: CIRCLE_RADIUS * 2, 14 | width: CIRCLE_RADIUS * 2, 15 | backgroundColor: '#ff1133', 16 | borderRadius: CIRCLE_RADIUS, 17 | } 18 | }) 19 | 20 | class PulsingCircle extends Component { 21 | constructor (props) { 22 | super(props) 23 | this.state = { 24 | scale: new Animated.Value(1), 25 | } 26 | } 27 | 28 | componentDidMount () { 29 | this.props.pulse && this.cyclicAnimate() 30 | } 31 | 32 | cyclicAnimate () { 33 | Animated.sequence([ 34 | Animated.timing( 35 | this.state.scale, { 36 | toValue: 1.1, 37 | duration: PULSING_INTERVAL, 38 | }), 39 | Animated.timing( 40 | this.state.scale, { 41 | toValue: 1, 42 | duration: PULSING_INTERVAL, 43 | }), 44 | ]).start(() => this.cyclicAnimate()) 45 | } 46 | 47 | render () { 48 | return ( 49 | 50 | ) 51 | } 52 | 53 | } 54 | 55 | PulsingCircle.propTypes = { 56 | 57 | } 58 | 59 | export default PulsingCircle 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-interactive-image 2 | 3 | An interactive image component for React Native. 4 | Accepts a lits of annotations with normalized x and y positions and displayes annotated interactive image. 5 | 6 | # Demo 7 | ![react-native-interactive-image Demo](https://github.com/chethann/demo-images/blob/master/demo-interactive-image.gif) 8 | 9 | # Usage 10 | 11 | ```javascript 12 | 19 | } 20 | ``` 21 | 22 | Where annotaions is a list of annotation which needs to be displayed, x and y postions are calculated taking mean of x1, x1 and y1, y2. An annotation is clickable within this region. Annotations used in demo: 23 | 24 | ```javascript 25 | const annotations = [ 26 | { 27 | x1: 25, 28 | x2: 35, 29 | y1: 20, 30 | y2: 30, 31 | description: 'A pair of black running sports shoes, has lace-up detail. Textile and mesh upper', 32 | }, 33 | { 34 | x1: 60, 35 | x2: 70, 36 | y1: 15, 37 | y2: 25, 38 | description: 'Shoe sole tip!', 39 | }, 40 | { 41 | x1: 20, 42 | x2: 30, 43 | y1: 50, 44 | y2: 60, 45 | description: 'Textured and patterned outsole', 46 | }, 47 | { 48 | x1: 65, 49 | x2: 75, 50 | y1: 65, 51 | y2: 75, 52 | description: 'Textured outsole with a stacked heel', 53 | }, 54 | ] 55 | ``` 56 | 57 | ### Installation 58 | - `npm install --save react-native-interactive-image` 59 | 60 | License 61 | ---- 62 | MIT 63 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react' 2 | import { Animated, ScrollView, StyleSheet, Dimensions, View, PanResponder, Image, TouchableWithoutFeedback, TouchableHighlight, Modal, Text } from 'react-native' 3 | import PulsingCircle from './PulsingCircle' 4 | 5 | const MAX_ZOOM = 2.5 6 | const ANIMATION_DURATION = 400 7 | const { height, width } = Dimensions.get('window') 8 | const POPUP_COLOR = 'white' 9 | 10 | const styles = StyleSheet.create({ 11 | container: { 12 | flex: 1, 13 | }, 14 | overlay: { 15 | flexGrow: 1, 16 | }, 17 | 18 | popupContainer: { 19 | marginHorizontal: 16, 20 | backgroundColor: POPUP_COLOR, 21 | padding: 8, 22 | borderRadius: 3, 23 | width: width - 40, 24 | }, 25 | 26 | popupText: { 27 | fontSize: 17, 28 | }, 29 | 30 | triangle: { 31 | width: 0, 32 | height: 0, 33 | backgroundColor: 'transparent', 34 | borderStyle: 'solid', 35 | borderLeftWidth: 6.5, 36 | borderRightWidth: 6.5, 37 | borderBottomWidth: 10, 38 | borderLeftColor: 'transparent', 39 | borderRightColor: 'transparent', 40 | borderBottomColor: POPUP_COLOR, 41 | }, 42 | }) 43 | 44 | class ZoomableImage extends Component { 45 | constructor(props) { 46 | super(props) 47 | this.popupRef = ref => this.popupRef = ref 48 | this.showPopup = this.showPopup.bind(this) 49 | this.state = { 50 | scale: new Animated.Value(1), 51 | inZoomedState: false, 52 | isZooming: false, 53 | offsetX: 0, 54 | offsetY: 0, 55 | popupY: 0, 56 | popupX: 0, 57 | popupArrowX: 0, 58 | arrowDirection: 'down', 59 | modalVisible: false, 60 | } 61 | this.onImagePress = this.onImagePress.bind(this) 62 | this.initValues = this.initValues.bind(this) 63 | this.closeModal = this.closeModal.bind(this) 64 | 65 | this.onMoveShouldSetPanResponder = this.onMoveShouldSetPanResponder.bind(this) 66 | this.onPanResponderMove = this.onPanResponderMove.bind(this) 67 | this.onPanResponderRelease = this.onPanResponderRelease.bind(this) 68 | this.onPanResponderTerminate = this.onPanResponderTerminate.bind(this) 69 | this.onPanResponderGrant = this.onPanResponderGrant.bind(this) 70 | 71 | this.trueFunction = () => true 72 | this.falseFunction = () => false 73 | this.previousDistanceX = 0 74 | this.previousDistanceY = 0 75 | 76 | this.imageRef = ref => this.imageRef = ref 77 | this.popupContentRef = ref => this.popupContentRef = ref 78 | } 79 | 80 | 81 | componentWillMount() { 82 | this.state.scale.addListener(this.initValues) 83 | this.initPanResponder() 84 | } 85 | 86 | initValues ({ value }) { 87 | this.scale = value 88 | let offsetX = (this.props.imageWidth / 2 - this.locationX ) * this.scale 89 | let offsetY = (this.props.imageHeight / 2 - this.locationY ) * this.scale 90 | const maxOffsetY = this.props.imageHeight * (this.scale -1) / 2 91 | const maxOffsetX = this.props.imageWidth * (this.scale -1) / 2 92 | 93 | this.offsetY = Math.abs(offsetY) > maxOffsetY ? (offsetY > 0 ? maxOffsetY : -maxOffsetY) : offsetY 94 | this.offsetX = Math.abs(offsetX) > maxOffsetX ? (offsetX > 0 ? maxOffsetX: -maxOffsetX ) : offsetX 95 | this.setState({ 96 | offsetX: this.offsetX, 97 | offsetY: this.offsetY, 98 | }) 99 | } 100 | 101 | initPanResponder () { 102 | const config = { 103 | onStartShouldSetPanResponder: this.trueFunction, 104 | onStartShouldSetPanResponderCapture: this.falseFunction, 105 | onMoveShouldSetPanResponder: this.onMoveShouldSetPanResponder, 106 | onMoveShouldSetPanResponderCapture: this.onMoveShouldSetPanResponder, 107 | onPanResponderMove: this.onPanResponderMove, 108 | onPanResponderTerminationRequest: this.trueFunction, 109 | onPanResponderRelease: this.onPanResponderRelease, 110 | onPanResponderTerminate: this.onPanResponderTerminate, 111 | onShouldBlockNativeResponder: this.trueFunction, 112 | onPanResponderGrant: this.onPanResponderGrant, 113 | } 114 | this.panResponder = PanResponder.create(config) 115 | } 116 | 117 | onPanResponderGrant (e, s) { 118 | this.previousDistanceX = 0 119 | this.previousDistanceY = 0 120 | this.previousScale = this.state.scale._value 121 | if (s.numberActiveTouches === 2) { 122 | const dx = Math.abs(e.nativeEvent.touches[0].pageX - e.nativeEvent.touches[1].pageX) 123 | const dy = Math.abs(e.nativeEvent.touches[0].pageY - e.nativeEvent.touches[1].pageY) 124 | const distance = Math.sqrt(dx * dx + dy * dy) 125 | this.distance = distance 126 | } 127 | } 128 | 129 | onMoveShouldSetPanResponder (e, s) { 130 | return s.numberActiveTouches === 2 || (this.state.inZoomedState && !this.state.isZooming) 131 | } 132 | 133 | onPanResponderMove (e, s) { 134 | // zoom 135 | if (s.numberActiveTouches === 2) { 136 | const dx = Math.abs(e.nativeEvent.touches[0].pageX - e.nativeEvent.touches[1].pageX) 137 | const dy = Math.abs(e.nativeEvent.touches[0].pageY - e.nativeEvent.touches[1].pageY) 138 | const distance = Math.sqrt(dx * dx + dy * dy) 139 | let scale = distance / this.distance * this.previousScale 140 | if ( scale < 1 ) { 141 | this.setState({ inZoomedState: false }) 142 | scale = 1 143 | } else if ( scale > MAX_ZOOM) { 144 | this.setState({ inZoomedState: true }) 145 | } 146 | Animated.timing( 147 | this.state.scale, 148 | { 149 | toValue: scale, 150 | duration: 1, 151 | useNativeDrive: true, 152 | } 153 | ).start() 154 | } else if (s.numberActiveTouches === 1) { 155 | const distanceMovedX = s.dx - this.previousDistanceX 156 | const distanceMovedY = s.dy - this.previousDistanceY 157 | this.previousDistanceX = s.dx 158 | this.previousDistanceY = s.dy 159 | let offsetX = this.state.offsetX + distanceMovedX 160 | let offsetY = this.state.offsetY + distanceMovedY 161 | const maxOffsetY = this.props.imageHeight * (this.scale -1) / 2 162 | const maxOffsetX = this.props.imageWidth * (this.scale -1) / 2 163 | this.locationX = this.locationX - distanceMovedX / this.previousScale 164 | this.locationY = this.locationY - distanceMovedY / this.previousScale 165 | this.offsetY = Math.abs(offsetY) > maxOffsetY ? (offsetY > 0 ? maxOffsetY : -maxOffsetY) : offsetY 166 | this.offsetX = Math.abs(offsetX) > maxOffsetX ? (offsetX > 0 ? maxOffsetX: -maxOffsetX ) : offsetX 167 | this.setState({ 168 | offsetX: this.offsetX, 169 | offsetY: this.offsetY, 170 | }) 171 | } 172 | } 173 | 174 | onPanResponderRelease (e, state) { 175 | } 176 | 177 | onPanResponderTerminate () { 178 | 179 | } 180 | 181 | normalizeAnnotation (annotation) { 182 | if (!annotation) 183 | return 184 | const x1 = annotation.x1 * this.props.imageWidth / 100 185 | const x2 = annotation.x2 * this.props.imageWidth / 100 186 | const y1 = annotation.y1 * this.props.imageHeight / 100 187 | const y2 = annotation.y2 * this.props.imageHeight / 100 188 | return { x1, x2, y1, y2 } 189 | } 190 | 191 | getAnnotation (x, y) { 192 | let match 193 | this.props.annotations && this.props.annotations.every(annotation => { 194 | const { x1, x2, y1, y2 } = this.normalizeAnnotation(annotation) 195 | if ( x > x1 && x < x2 && y > y1 && y < y2 ) 196 | match = annotation 197 | return !match 198 | }) 199 | return match 200 | } 201 | 202 | showPopup () { 203 | this.setState({ modalVisible: true }) 204 | } 205 | 206 | onImagePress (e) { 207 | const { nativeEvent: { locationX = 0, locationY = 0, pageX, pageY } = {} } = e 208 | this.currentAnnotation = this.getAnnotation(locationX, locationY) 209 | if (this.currentAnnotation && !this.state.inZoomedState) { 210 | this.setState({ 211 | popupY: pageY, 212 | }) 213 | this.locationX = locationX 214 | this.locationY = locationY 215 | this.pageX = pageX 216 | this.pageY = pageY 217 | this.zoomUpImage() 218 | } 219 | else if (this.state.inZoomedState) 220 | this.zoomDownImage() 221 | } 222 | 223 | zoomUpImage () { 224 | this.setState({ isZooming: true }) 225 | Animated.timing( 226 | this.state.scale, 227 | { 228 | toValue: MAX_ZOOM, 229 | duration: ANIMATION_DURATION, 230 | useNativeDrive: true, 231 | } 232 | ).start(() => { 233 | this.setState({ inZoomedState: true, isZooming: false }) 234 | if (this.currentAnnotation) 235 | this.showPopup() 236 | } 237 | ) 238 | } 239 | 240 | zoomDownImage () { 241 | this.setState({ isZooming: true }) 242 | Animated.timing( 243 | this.state.scale, 244 | { 245 | toValue: 1, 246 | duration: ANIMATION_DURATION, 247 | useNativeDrive: true, 248 | } 249 | ).start(() => this.setState({ inZoomedState: false, offsetX: 0, offsetY: 0, isZooming: false })) 250 | } 251 | 252 | closeModal () { 253 | this.setState({ modalVisible: false }) 254 | } 255 | 256 | renderTouchpoints () { 257 | if (this.state.isZooming || this.state.inZoomedState) 258 | return null 259 | return this.props.annotations && this.props.annotations.map(annotation => { 260 | const style = { 261 | position: 'absolute', 262 | left: ((annotation.x2 + annotation.x1) / 200) * this.props.imageWidth, 263 | top: ((annotation.y2 + annotation.y1) / 200) * this.props.imageHeight, 264 | } 265 | return ( 266 | 267 | ) 268 | }) 269 | } 270 | 271 | popupContent () { 272 | return ( 273 | 274 | 275 | { this.currentAnnotation && this.currentAnnotation.description } 276 | 277 | ) 278 | } 279 | 280 | popupContainer () { 281 | const backgroundColorStyle = { opacity: 0.3, flexGrow: 1 } 282 | return ( 287 | 288 | 289 | 294 | 295 | 296 | 297 | { this.popupContent() } 298 | 299 | ) 300 | } 301 | 302 | render() { 303 | const tansformStyles = { transform: [ 304 | { scale: this.state.scale }, 305 | 306 | ] } 307 | return ( 308 | 309 | {this.popupContainer()} 310 | 316 | 320 | 325 | 326 | 327 | 328 | { this.renderTouchpoints() } 329 | 330 | ) 331 | } 332 | } 333 | 334 | ZoomableImage.propTypes = { 335 | imageWidth: PropTypes.number.isRequired, 336 | imageHeight: PropTypes.number.isRequired, 337 | source: PropTypes.object.isRequired, 338 | }; 339 | export default ZoomableImage 340 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-interactive-image", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "index.js", 6 | "keywords": [ 7 | "react", 8 | "react-native", 9 | "images", 10 | "react-native-interactive-image", 11 | "react-native-image" 12 | ], 13 | "author": "chethann12793@gmail.com", 14 | "license": "MIT" 15 | } 16 | --------------------------------------------------------------------------------