├── 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 | 
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 |
--------------------------------------------------------------------------------