UNPKG

@s20.ai/safecam-360-rn

Version:

A React Native component to view the interior panorama and exterior 360 degree views of a product featuring suppport for hotspots. Compatible with Android and iOS

887 lines (835 loc) 27.4 kB
/* * Copyright © 2022 S20.AI India Pvt. Ltd. * Contact - origin@s20.ai * License - Licensed as per https://docs.s20.ai/license.html */ import React, {useEffect, useRef, useState} from 'react'; import { View, Text, StyleSheet, Dimensions, TouchableHighlight, PanResponder, LogBox, Image, SafeAreaView, StatusBar, } from 'react-native'; import FastImage from 'react-native-fast-image'; import HotspotElement from '../hotspot/HotspotElement'; import {connect} from 'react-redux'; import constants from '../../config/constants'; import {useDispatch} from 'react-redux'; import {setCurrentOrientation} from '../../redux/actions/currentViewAction'; import {setWidgetsVisible} from '../../redux/actions/currentViewAction'; import { setCurrentMessageOff, setCurrentMessageOn, } from '../../redux/actions/messageHintAction'; import Animated from 'react-native-reanimated'; import ProgressBar from '../common/ProgressBar'; import {setCurrentHotspot} from '../../redux/actions/currentHotspotAction'; import Orientation from 'react-native-orientation-locker'; import DragToRotateViewMessage from '../common/overlay/DragToRotateViewMessage'; import {getAspectRatio, useScreenDimensions} from '../../utils/device'; import {responsiveAppStyleConfig} from '../responsiveAppStyleConfig'; import ProgressiveImage from '../common/ProgressiveImage'; const screen = Dimensions.get('window'); let screenWidth = screen.width; let screenHeight = screen.height; LogBox.ignoreLogs(['Warning: ...']); const AnimatedFastImage = Animated.createAnimatedComponent(FastImage); const clearMyInterval = interval => { clearInterval(interval); }; const calcDistance = (x1, y1, x2, y2) => { let dx = Math.abs(x1 - x2); let dy = Math.abs(y1 - y2); return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)); }; const calcCenter = (x1, y1, x2, y2) => { const middle = (p1, p2) => { return p1 > p2 ? p1 - (p1 - p2) / 2 : p2 - (p2 - p1) / 2; }; return { x: middle(x1, x2), y: middle(y1, y2), }; }; const maxOffset = (offset, windowDimension, imageDimension) => { let max = windowDimension - imageDimension; if (max >= 0) { return 0; } return offset < max ? max : offset; }; const calcOffsetByZoom = (width, height, imageWidth, imageHeight, zoom) => { let xDiff = imageWidth * zoom - width; let yDiff = imageHeight * zoom - height; return { left: -xDiff / 2, top: -yDiff / 2, }; }; let startX = 0; let startRotation = 0; let rotation = 0; let myInterval; let _currentIndex = 0; let isZooming = false; let initialDistance = null; let initialTop = 0; let initialLeft = 0; let initialX = null; let initialY = null; let initialTopWithoutZoom = 0; let initialLeftWithoutZoom = 0; let initialZoom = 1; let minZoom = null; let maxZoom = 8; let initialPan = {x: 0, y: 0}; let _zoom = 1; let _isDragging = false; let _isLoading = true; let _panCoords = {x: 0, y: 0}; let lastPress = 0; let _currentHotspotState = null; let reverseAutoRotateTimer; let _orientation = constants.PORTRAIT; let _noOfImagesLoaded = 0; const Exterior360 = ({ // height = 300, highResImages = [], srcset = [], srcsetCompressed = [], hotspots = [], rotationRatio = 0.25, currentHotspotState, filteredHotspotMetadata, currentViewState, hotspotMode, orientation, enableHotspots, messageHint, enableHotspotTooltip, enableCarAutorotateOnCardSwipe, enableCardSwipeOnCarRotation, hotspotPressed, onInteraction, customFontFamily, exterior360ResizeMode, }) => { const getImageWidth = () => { return orientation.includes(constants.LANDSCAPE) ? currentHotspotState ? Dimensions.get('window').width / 1.65 : Dimensions.get('window').width : Dimensions.get('window').width; }; const getImageHeight = () => { return orientation.includes(constants.LANDSCAPE) ? Dimensions.get('window').height : Dimensions.get('window').width / 1.8; }; const dispatch = useDispatch(); // let imageWidth = // orientation.includes(constants.LANDSCAPE)'50% // ? currentHotspotState // ? Dimensions.get('window').width / 1.65 // : Dimensions.get('window').width // : Dimensions.get('window').width; // let imageHeight = // orientation.includes(constants.LANDSCAPE) // ? Dimensions.get('window').height // : Dimensions.get('window').width / 1.9; let imageWidth = getImageWidth(); let imageHeight = getImageHeight(); const [isLoading, setIsLoading] = useState(true); const [noOfImagesLoaded, setNoOfImagesLoaded] = useState(0); const rotationPeriod = 360 / srcset.length; //Expression used to set the current frame index to the 45 degree left front view //TODO - the index should be fetched from the prop data directly //Below index calculation should just be a fallback const [currentIndex, setCurrentIndex] = useState( Math.round(45 / (360 / srcset.length)), ); const [width, setwidth] = useState(0); const [height, setheight] = useState(0); const [zoom, setzoom] = useState(1); const [layoutKnown, setlayoutKnown] = useState(false); const [offsetTop, setoffsetTop] = useState(0); const [panCoords, setPanCoords] = useState({x: 0, y: 0}); const [top, settop] = useState(0); const [left, setleft] = useState(0); const [highResMode, setHighResMode] = useState(false); const [highResLoaded, setHighResLoaded] = useState(false); const frameSkipAutoRotate = 3; _orientation = orientation; const [hotspotsVisible, setHotspotsVisible] = useState( zoom <= 1 && enableHotspots && hotspotMode === constants.SHOW_ALL_HOTSPOTS && !isLoading, ); useEffect(() => { setHotspotsVisible( zoom <= 1 && enableHotspots && hotspotMode === constants.SHOW_ALL_HOTSPOTS && !isLoading, ); }, [hotspotMode]); useEffect(() => { if (zoom > 1) setHighResMode(true); else setHighResMode(false); if (zoom > 1 || _zoom > 1) { if (orientation.includes(constants.LANDSCAPE)) { dispatch(setWidgetsVisible(false)); } else { let imageButtonOffsetFromBottom = Dimensions.get('window').height * 0.08 * 0.67 + 24; let imageButtonPositionFromTop = Dimensions.get('window').height - imageButtonOffsetFromBottom; if (imageHeight * zoom >= imageButtonPositionFromTop) { dispatch(setWidgetsVisible(false)); } else { dispatch(setWidgetsVisible(true)); } } } else { dispatch(setWidgetsVisible(true)); } }, [zoom]); useEffect(() => { if (_isDragging && orientation.includes(constants.LANDSCAPE)) { dispatch(setWidgetsVisible(false)); } }, [_isDragging]); const userDragHandler_ReverseAutoRotate = () => { if (_currentHotspotState) { let hotspotToToggle = hotspots.filter( ({frameId, display}) => frameId === _currentIndex && display[2], ); if (hotspotToToggle.length) { let pin = hotspotToToggle[0]; window.clearTimeout(reverseAutoRotateTimer); reverseAutoRotateTimer = setTimeout(() => { dispatch( setCurrentHotspot( filteredHotspotMetadata.find( ({hotspotRefId: id}) => pin.hotspotRefId === id, ), ), ); }, 25); } // displayedHotspots.filter(pin => = false ) } }; const panResponder = React.useRef( PanResponder.create({ onMoveShouldSetPanResponder: (evt, gestureState) => { const {dx, dy} = gestureState; return dx > 2 || dx < -2 || dy > 2 || dy < -2; }, onPanResponderGrant: (evt, gestureState) => { dispatch( setCurrentMessageOff(constants.MESSAGES.EXTERIOR_ROTATE, true), ); startX = gestureState.moveX; let touches = evt.nativeEvent.touches; if (touches.length == 1 && _zoom <= 1) { if (onInteraction) { onInteraction({ currentView: constants.EXTERIOR_VIEW, interaction: { type: constants.INTERACTION_TYPES.DRAG_START, }, }); } } if (touches.length == 1 && _zoom > 1 && !isZooming) { if (onInteraction) { onInteraction({ currentView: constants.EXTERIOR_VIEW, interaction: { type: constants.INTERACTION_TYPES.PAN_START, }, }); } let maxPanX = (imageWidth * _zoom - imageWidth) / (2 * _zoom); if (maxPanX >= Math.abs(_panCoords.x)) { initialX = gestureState.dx; initialY = gestureState.dy; initialPan = {x: _panCoords.x, y: _panCoords.y}; } } // setstartX(gestureState.moveX); startRotation = rotation; }, onPanResponderMove: (evt, gestureState) => { let touches = evt.nativeEvent.touches; if (!_isLoading) { if (touches.length == 2) { processPinch( touches[0].pageX, touches[0].pageY, touches[1].pageX, touches[1].pageY, ); } else if (touches.length == 1) { if (_zoom > 1.0001 && !isZooming) { let maxPanX = (getImageWidth() * _zoom - Dimensions.get('window').width) / (2 * _zoom); let maxPanY = (getImageHeight() * _zoom - Dimensions.get('window').height / 1) / (1 * _zoom); let xVal = initialPan.x + gestureState.dx - initialX; let yVal = initialPan.y + gestureState.dy - initialY; _panCoords = { x: maxPanX > Math.abs(xVal) ? xVal : _panCoords.x, y: maxPanY > Math.abs(yVal) ? yVal : _panCoords.y, }; setPanCoords(_panCoords); } else if (_zoom <= 1.0001) { initialX = 0; initialY = 0; _panCoords = {x: 0, y: 0}; _isDragging = true; setPanCoords(_panCoords); if (enableCardSwipeOnCarRotation) { userDragHandler_ReverseAutoRotate(); } rotateCar(gestureState); } } } }, onPanResponderRelease: (evt, gestureState) => { if (isZooming) { let touches = evt.nativeEvent.touches; if (onInteraction) { onInteraction({ currentView: constants.EXTERIOR_VIEW, interaction: { type: constants.INTERACTION_TYPES.ZOOM_END, }, }); } isZooming = false; } else { if (_panCoords.x == 0 && _panCoords.y == 0) { if (!currentHotspotState) { dispatch(setWidgetsVisible(true)); } if (onInteraction) { onInteraction({ currentView: constants.EXTERIOR_VIEW, interaction: { type: constants.INTERACTION_TYPES.DRAG_END, }, }); } } else { if (onInteraction) { onInteraction({ currentView: constants.EXTERIOR_VIEW, interaction: { type: constants.INTERACTION_TYPES.PAN_END, }, }); } } } _isDragging = false; }, }), ).current; const processPinch = (x1, y1, x2, y2) => { let distance = calcDistance(x1, y1, x2, y2); let center = calcCenter(x1, y1, x2, y2); if (!isZooming) { let offsetByZoom = calcOffsetByZoom( width, height, imageWidth, imageHeight, _zoom, ); if (onInteraction) { onInteraction({ currentView: constants.EXTERIOR_VIEW, interaction: { type: constants.INTERACTION_TYPES.ZOOM_START, }, }); } isZooming = true; initialDistance = distance; initialX = center.x; initialY = center.y; initialTop = top; initialLeft = left; initialZoom = _zoom; initialTopWithoutZoom = top - offsetByZoom.top; initialLeftWithoutZoom = left - offsetByZoom.left; } else { let touchZoom = distance / initialDistance; let zoomVal = touchZoom * initialZoom > minZoom ? touchZoom * initialZoom : minZoom; zoomVal = maxZoom >= zoomVal ? zoomVal : maxZoom; let offsetByZoom = calcOffsetByZoom( width, height, imageWidth, imageHeight, zoomVal, ); let left = initialLeftWithoutZoom * touchZoom + offsetByZoom.left; let top = initialTopWithoutZoom * touchZoom + offsetByZoom.top; _zoom = zoomVal; setzoom(zoomVal); setleft(0); settop(0); setleft(left > 0 ? 0 : maxOffset(left, width, imageWidth * zoomVal)); settop(top > 0 ? 0 : maxOffset(top, height, imageHeight * zoomVal)); if (zoomVal <= 1) { isZooming = false; setPanCoords({x: 0, y: 0}); } } }; const _onLayout = event => { let layout = event.nativeEvent.layout; if (layout.width === width && layout.height === height) { return; } let zoom = layout.width / getImageWidth(); let offsetTop = layout.height > getImageHeight() * zoom ? (layout.height - getImageHeight() * zoom) / 2 : 0; setlayoutKnown(true); setwidth(layout.width); setheight(layout.height); _zoom = zoom; setzoom(zoom.toFixed()); setoffsetTop(offsetTop); minZoom = zoom.toFixed(); }; const rotateCar = gestureState => { setHighResLoaded(false); const fs = 1; const deltaRotation = ((gestureState.moveX - startX) * 180) / (rotationRatio * screenWidth); rotation = startRotation - deltaRotation; const mRotation = rotation - Math.floor(rotation / 360) * 360; const index = Math.floor(mRotation / rotationPeriod); if (_currentIndex > index) _currentIndex = index + fs > srcset.length - 1 ? 0 : index + fs; else _currentIndex = index - fs < 0 ? srcset.length - 1 : index - fs; setCurrentIndex(_currentIndex); }; const displayAutoRotateMessage = () => { if ( !orientation.includes(constants.LANDSCAPE) && !messageHint[constants.MESSAGES.AUTO_ROTATE_MESSAGE].show && !messageHint[constants.MESSAGES.AUTO_ROTATE_MESSAGE].never ) { dispatch(setCurrentMessageOn(constants.MESSAGES.AUTO_ROTATE_MESSAGE)); } }; const displayDragToRotateMessage = () => { if ( !messageHint[constants.MESSAGES.EXTERIOR_ROTATE].show && !messageHint[constants.MESSAGES.EXTERIOR_ROTATE].never ) { dispatch(setCurrentMessageOn(constants.MESSAGES.EXTERIOR_ROTATE)); } }; useEffect(() => { if (!orientation.includes(constants.PORTRAIT)) { if (messageHint[constants.MESSAGES.AUTO_ROTATE_MESSAGE].show) { dispatch(setCurrentMessageOff(constants.MESSAGES.AUTO_ROTATE_MESSAGE)); } } _zoom = 1; setzoom(_zoom); _panCoords = {x: 0, y: 0}; setPanCoords(_panCoords); _orientation = orientation; }, [orientation]); const updateStartRotation = () => { rotation = _currentIndex * rotationPeriod; }; const autoRotateCar = (rotateTo = null) => { if (currentHotspotState || rotateTo) { let triggerFrame = rotateTo || currentHotspotState.triggerFrame; myInterval = setInterval(() => { if (_currentIndex + frameSkipAutoRotate < triggerFrame) { if (_currentIndex + frameSkipAutoRotate < srcset.length) { _currentIndex += frameSkipAutoRotate; } else { _currentIndex = 0; } setCurrentIndex(_currentIndex); updateStartRotation(); } else if (_currentIndex - frameSkipAutoRotate > triggerFrame) { if (_currentIndex - frameSkipAutoRotate >= 0) { _currentIndex -= frameSkipAutoRotate; } else { _currentIndex = srcset.length - 1; } setCurrentIndex(_currentIndex); updateStartRotation(); } else { _currentIndex = triggerFrame; setCurrentIndex(_currentIndex); updateStartRotation(); clearMyInterval(myInterval); } }, 1); } }; useEffect(() => { if (enableCarAutorotateOnCardSwipe) { if (!_isDragging) autoRotateCar(); _currentHotspotState = currentHotspotState; } }, [currentHotspotState]); const renderHotspots = () => { return hotspots .filter( d => d.frameId === srcset.findIndex(d => d === srcset[currentIndex]), ) .map(hotspot => { return ( <HotspotElement customFontFamily={customFontFamily} key={hotspot.id + Math.random()} address={ filteredHotspotMetadata.find( hp => hp.hotspotRefId === hotspot.hotspotRefId, ).address } hotspot={hotspot} hotspotPressed={hotspotPressed} hotspotLocation={ filteredHotspotMetadata.find( hp => hp.hotspotRefId === hotspot.hotspotRefId, ).type } hotspotType={ filteredHotspotMetadata.find( hp => hp.hotspotRefId === hotspot.hotspotRefId, ).hotspotType } enableHotspotTooltip={enableHotspotTooltip} currentHotspotState={currentHotspotState} width={imageWidth} height={imageHeight} orientationState={orientation} /> ); }); }; useEffect(() => { if (messageHint[constants.MESSAGES.EXTERIOR_ROTATE].show) { setHotspotsVisible(false); } else { setHotspotsVisible( zoom <= 1 && enableHotspots && hotspotMode === constants.SHOW_ALL_HOTSPOTS && !isLoading, ); } }, [messageHint]); useEffect(() => { if ( srcset.length === noOfImagesLoaded && currentViewState.type === constants.EXTERIOR_VIEW ) { setIsLoading(false); displayDragToRotateMessage(); _isLoading = false; } }, [noOfImagesLoaded, currentViewState]); useEffect(() => { displayAutoRotateMessage(); }, []); const renderImage = (link, linkIndex) => { return ( <> <FastImage onLoadEnd={_ => { _noOfImagesLoaded += 1; setNoOfImagesLoaded(_noOfImagesLoaded); }} key={link} fadeDuration={0} resizeMode={ exterior360ResizeMode && [ FastImage.resizeMode.center, FastImage.resizeMode.contain, FastImage.resizeMode.cover, FastImage.resizeMode.stretch, ].includes(exterior360ResizeMode) ? exterior360ResizeMode : FastImage.resizeMode.cover } source={{ uri: link, }} style={{ // position: 'absolute', width: imageWidth, height: imageHeight, transform: [ { scale: highResLoaded && zoom > 4 ? 4 : parseFloat(zoom), }, {translateX: panCoords.x}, {translateY: panCoords.y}, ], opacity: currentIndex && !isNaN(currentIndex) ? srcset[currentIndex] === link ? 1 : 0 : 1, zIndex: zoom < 3 && !highResLoaded ? 1 : -1, }}></FastImage> {/* <ProgressiveImage noOfImagesLoaded={noOfImagesLoaded} setNoOfImagesLoaded={(val)=>{ setNoOfImagesLoaded(val) }} isLoading={isLoading} key={link} fadeDuration={0} thumbnailSource={{ uri: srcsetCompressed[linkIndex]}} resizeMode={exterior360ResizeMode && [FastImage.resizeMode.center, FastImage.resizeMode.contain, FastImage.resizeMode.cover, FastImage.resizeMode.stretch].includes(exterior360ResizeMode) ? exterior360ResizeMode : FastImage.resizeMode.cover} source={{ uri: link, }} style={{ // position: 'absolute', width: imageWidth, height: imageHeight, transform: [ { scale: highResLoaded && zoom > 4 ? 4 : parseFloat(zoom), }, {translateX: panCoords.x}, {translateY: panCoords.y}, ], opacity: currentIndex && !isNaN(currentIndex) ? srcset[currentIndex] === link ? 1 : 0 : 1, zIndex: zoom < 3 && !highResLoaded ? 1 : -1, }}></ProgressiveImage> */} {zoom > 3 && ( /* <FastImage key={highResImages[currentIndex]} fadeDuration={0} onLoadStart={_ => { setHighResLoaded(false); }} onLoadEnd={_ => { setHighResLoaded(true); }} resizeMode={FastImage.resizeMode.contain} source={{ uri: highResImages[currentIndex], priority: 'high', }} style={{ position: 'absolute', width: imageWidth, height: imageHeight, transform: [ { scale: parseFloat(zoom), }, {translateX: panCoords.x}, {translateY: panCoords.y}, ], zIndex: highResLoaded ? 0 : -1, elevation: highResLoaded ? 1 : -1, }} /> */ /** Used to handle high quality image on adaptive zoom */ <Image key={highResImages[currentIndex]} fadeDuration={0} onLoadStart={_ => { setHighResLoaded(false); }} onLoadEnd={_ => { setHighResLoaded(true); }} resizeMode={'contain'} source={{ // uri: link, uri: highResImages[currentIndex], }} style={{ position: 'absolute', width: imageWidth, height: imageHeight, transform: [ { scale: parseFloat(zoom), }, {translateX: panCoords.x}, {translateY: panCoords.y}, ], zIndex: zoom < 3 && !highResLoaded ? -1 : 1, }} /> )} </> ); }; useEffect(() => { if (currentViewState.type === constants.EXTERIOR_VIEW) { //random bug whereby exterior 360 image does not show when switching back and forth between views //reason is that these values are set to NaN for some reason //they need to be reset every time the exterior view loads so as to prevent this bug startRotation = 0; rotation = 0; } }, [currentViewState]); const screenDimensions = useScreenDimensions(); const exteriorViewContainerStyles = responsiveAppStyleConfig.stylePicker( 'exteriorView', screenDimensions.width, ); const exteriorViewImageWrapperStyles = responsiveAppStyleConfig.stylePicker( 'exteriorViewImageWrapper', screenDimensions.width, ); return ( <View style={[ { ...exteriorViewContainerStyles.container, ...(orientation.includes(constants.LANDSCAPE) ? {width: currentHotspotState ? '50%' : '100%'} : currentHotspotState ? {...exteriorViewContainerStyles.containerHotspotMode} : {...exteriorViewContainerStyles.container}), }, { opacity: currentViewState.type === constants.EXTERIOR_VIEW ? 1 : 0, zIndex: currentViewState.type === constants.EXTERIOR_VIEW ? 0 : -10, }, ]} onLayout={_onLayout}> <View {...panResponder.panHandlers} style={exteriorViewImageWrapperStyles}> {srcset.map((link, linkIndex) => ( <TouchableHighlight key={link} activeOpacity={0} style={{ position: 'absolute', // top: imageHeight * zoom, width: imageWidth, height: imageHeight, //sometimes currentIndex may be NaN //if this happens, the image will not show //reset opacity to 1 if this happens opacity: currentIndex && !isNaN(currentIndex) ? srcset[currentIndex] === link ? 1 : 0 : 1, }} onPress={e => { const time = new Date().getTime(); const delta = time - lastPress; const DOUBLE_PRESS_DELAY = 400; if (delta < DOUBLE_PRESS_DELAY) { dispatch( setCurrentMessageOff( constants.MESSAGES.EXTERIOR_ROTATE, true, ), ); if (onInteraction) { onInteraction({ currentView: constants.EXTERIOR_VIEW, interaction: { type: constants.INTERACTION_TYPES.DOUBLE_TAP, }, }); } let zoomVal = 1; if (zoom <= 2) zoomVal = 4; else { _panCoords = {x: 0, y: 0}; initialX = 0; initialY = 0; setPanCoords({x: 0, y: 0}); isZooming = false; } _zoom = zoomVal; setzoom(zoomVal); dispatch(setWidgetsVisible(false)); } else { if (currentHotspotState) { } dispatch(setWidgetsVisible(true)); } lastPress = time; }}> {renderImage(link, linkIndex)} </TouchableHighlight> ))} {hotspotsVisible && zoom <= 1 && renderHotspots()} {isLoading && ( <ProgressBar imageHeight={imageHeight} totalImages={srcset.length} noOfImagesLoaded={noOfImagesLoaded} orientation={orientation} /> )} {!_isDragging && messageHint[constants.MESSAGES.EXTERIOR_ROTATE].show && ( <DragToRotateViewMessage imageHeight={imageHeight} customFontFamily={customFontFamily} /> )} </View> </View> ); }; const mapStateToProps = ({ currentView, currentHotspot, hotspotMode, messageHint, }) => { return { currentViewState: currentView.present, orientation: currentView.orientation, widgetsVisible: currentView.widgetsVisible, currentHotspotState: currentHotspot, hotspotMode: hotspotMode, messageHint: messageHint, }; }; export default connect(mapStateToProps)(Exterior360);