Saves image on long press (#83)

* Saves image on long press

* Adds save on long press

* Forking lightbox

* move to wrapper only to the bottom sheet to reduce impact of this change

* lint

* lint

* lint

* Use official `share` API

* Clean up cache after download

* comment

* comment

* Reduce swipe close velocity

* Updates per feedback

* lint

* bugfix

* Adds delayed press-in for TouchableOpacity
This commit is contained in:
Aryan Goharzad 2023-01-25 18:25:34 -05:00 committed by GitHub
parent adf328b50c
commit eb33c3fa81
23 changed files with 1568 additions and 46 deletions

View file

@ -0,0 +1,52 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import React from 'react'
import {SafeAreaView, Text, TouchableOpacity, StyleSheet} from 'react-native'
type Props = {
onRequestClose: () => void
}
const HIT_SLOP = {top: 16, left: 16, bottom: 16, right: 16}
const ImageDefaultHeader = ({onRequestClose}: Props) => (
<SafeAreaView style={styles.root}>
<TouchableOpacity
style={styles.closeButton}
onPress={onRequestClose}
hitSlop={HIT_SLOP}>
<Text style={styles.closeText}></Text>
</TouchableOpacity>
</SafeAreaView>
)
const styles = StyleSheet.create({
root: {
alignItems: 'flex-end',
},
closeButton: {
marginRight: 8,
marginTop: 8,
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 22,
backgroundColor: '#00000077',
},
closeText: {
lineHeight: 22,
fontSize: 19,
textAlign: 'center',
color: '#FFF',
includeFontPadding: false,
},
})
export default ImageDefaultHeader

View file

@ -0,0 +1,152 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import React, {useCallback, useRef, useState} from 'react'
import {
Animated,
ScrollView,
Dimensions,
StyleSheet,
NativeScrollEvent,
NativeSyntheticEvent,
NativeMethodsMixin,
} from 'react-native'
import useImageDimensions from '../../hooks/useImageDimensions'
import usePanResponder from '../../hooks/usePanResponder'
import {getImageStyles, getImageTransform} from '../../utils'
import {ImageSource} from '../../@types'
import {ImageLoading} from './ImageLoading'
const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1.75
const SCREEN = Dimensions.get('window')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
type Props = {
imageSrc: ImageSource
onRequestClose: () => void
onZoom: (isZoomed: boolean) => void
onLongPress: (image: ImageSource) => void
delayLongPress: number
swipeToCloseEnabled?: boolean
doubleTapToZoomEnabled?: boolean
}
const ImageItem = ({
imageSrc,
onZoom,
onRequestClose,
onLongPress,
delayLongPress,
swipeToCloseEnabled = true,
doubleTapToZoomEnabled = true,
}: Props) => {
const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
const imageDimensions = useImageDimensions(imageSrc)
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
const scrollValueY = new Animated.Value(0)
const [isLoaded, setLoadEnd] = useState(false)
const onLoaded = useCallback(() => setLoadEnd(true), [])
const onZoomPerformed = useCallback(
(isZoomed: boolean) => {
onZoom(isZoomed)
if (imageContainer?.current) {
imageContainer.current.setNativeProps({
scrollEnabled: !isZoomed,
})
}
},
[onZoom],
)
const onLongPressHandler = useCallback(() => {
onLongPress(imageSrc)
}, [imageSrc, onLongPress])
const [panHandlers, scaleValue, translateValue] = usePanResponder({
initialScale: scale || 1,
initialTranslate: translate || {x: 0, y: 0},
onZoom: onZoomPerformed,
doubleTapToZoomEnabled,
onLongPress: onLongPressHandler,
delayLongPress,
})
const imagesStyles = getImageStyles(
imageDimensions,
translateValue,
scaleValue,
)
const imageOpacity = scrollValueY.interpolate({
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
outputRange: [0.7, 1, 0.7],
})
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
const onScrollEndDrag = ({
nativeEvent,
}: NativeSyntheticEvent<NativeScrollEvent>) => {
const velocityY = nativeEvent?.velocity?.y ?? 0
const offsetY = nativeEvent?.contentOffset?.y ?? 0
if (
(Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
offsetY > SWIPE_CLOSE_OFFSET) ||
offsetY > SCREEN_HEIGHT / 2
) {
onRequestClose()
}
}
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = nativeEvent?.contentOffset?.y ?? 0
scrollValueY.setValue(offsetY)
}
return (
<ScrollView
ref={imageContainer}
style={styles.listItem}
pagingEnabled
nestedScrollEnabled
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.imageScrollContainer}
scrollEnabled={swipeToCloseEnabled}
{...(swipeToCloseEnabled && {
onScroll,
onScrollEndDrag,
})}>
<Animated.Image
{...panHandlers}
source={imageSrc}
style={imageStylesWithOpacity}
onLoad={onLoaded}
/>
{(!isLoaded || !imageDimensions) && <ImageLoading />}
</ScrollView>
)
}
const styles = StyleSheet.create({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
},
imageScrollContainer: {
height: SCREEN_HEIGHT * 2,
},
})
export default React.memo(ImageItem)

View file

@ -0,0 +1,152 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import React, {useCallback, useRef, useState} from 'react'
import {
Animated,
Dimensions,
ScrollView,
StyleSheet,
View,
NativeScrollEvent,
NativeSyntheticEvent,
TouchableWithoutFeedback,
} from 'react-native'
import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom'
import useImageDimensions from '../../hooks/useImageDimensions'
import {getImageStyles, getImageTransform} from '../../utils'
import {ImageSource} from '../../@types'
import {ImageLoading} from './ImageLoading'
const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1
const SCREEN = Dimensions.get('screen')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
type Props = {
imageSrc: ImageSource
onRequestClose: () => void
onZoom: (scaled: boolean) => void
onLongPress: (image: ImageSource) => void
delayLongPress: number
swipeToCloseEnabled?: boolean
doubleTapToZoomEnabled?: boolean
}
const ImageItem = ({
imageSrc,
onZoom,
onRequestClose,
onLongPress,
delayLongPress,
swipeToCloseEnabled = true,
doubleTapToZoomEnabled = true,
}: Props) => {
const scrollViewRef = useRef<ScrollView>(null)
const [loaded, setLoaded] = useState(false)
const [scaled, setScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc)
const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN)
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
const scrollValueY = new Animated.Value(0)
const scaleValue = new Animated.Value(scale || 1)
const translateValue = new Animated.ValueXY(translate)
const maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1
const imageOpacity = scrollValueY.interpolate({
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
outputRange: [0.5, 1, 0.5],
})
const imagesStyles = getImageStyles(
imageDimensions,
translateValue,
scaleValue,
)
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
const onScrollEndDrag = useCallback(
({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
const velocityY = nativeEvent?.velocity?.y ?? 0
const currentScaled = nativeEvent?.zoomScale > 1
onZoom(currentScaled)
setScaled(currentScaled)
if (
!currentScaled &&
swipeToCloseEnabled &&
Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY
) {
onRequestClose()
}
},
[onRequestClose, onZoom, swipeToCloseEnabled],
)
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = nativeEvent?.contentOffset?.y ?? 0
if (nativeEvent?.zoomScale > 1) {
return
}
scrollValueY.setValue(offsetY)
}
const onLongPressHandler = useCallback(() => {
onLongPress(imageSrc)
}, [imageSrc, onLongPress])
return (
<View>
<ScrollView
ref={scrollViewRef}
style={styles.listItem}
pinchGestureEnabled
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
maximumZoomScale={maxScale}
contentContainerStyle={styles.imageScrollContainer}
scrollEnabled={swipeToCloseEnabled}
onScrollEndDrag={onScrollEndDrag}
scrollEventThrottle={1}
{...(swipeToCloseEnabled && {
onScroll,
})}>
{(!loaded || !imageDimensions) && <ImageLoading />}
<TouchableWithoutFeedback
onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined}
onLongPress={onLongPressHandler}
delayLongPress={delayLongPress}>
<Animated.Image
source={imageSrc}
style={imageStylesWithOpacity}
onLoad={() => setLoaded(true)}
/>
</TouchableWithoutFeedback>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
},
imageScrollContainer: {
height: SCREEN_HEIGHT,
},
})
export default React.memo(ImageItem)

View file

@ -0,0 +1,37 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import React from 'react'
import {ActivityIndicator, Dimensions, StyleSheet, View} from 'react-native'
const SCREEN = Dimensions.get('screen')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
export const ImageLoading = () => (
<View style={styles.loading}>
<ActivityIndicator size="small" color="#FFF" />
</View>
)
const styles = StyleSheet.create({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
},
loading: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
alignItems: 'center',
justifyContent: 'center',
},
imageScrollContainer: {
height: SCREEN_HEIGHT,
},
})