From 44ac06ad7dc0f2be78cb0eaf1eb9b0d09137869b Mon Sep 17 00:00:00 2001 From: Danish Ahmed Mirza <77742477+try-catch-stack@users.noreply.github.com> Date: Fri, 24 Jun 2022 01:49:42 +0530 Subject: [PATCH] [NEW] ImageViewer animations using new API from `react-native-gesture-handler` and `react-native-reanimated` v2 (#4221) * Update ImageViewer to reanimated and RNGH v2 API * Move styles outside the component * Fix issues with pinch gesture Co-authored-by: Gleidson Daniel Silva --- .../ImageViewer/ImageComponent.ts | 0 app/containers/ImageViewer/ImageViewer.tsx | 125 ++++++ .../ImageViewer/index.ts | 0 .../ImageViewer/types.ts | 0 .../ImageViewer/ImageViewer.android.tsx | 386 ------------------ app/presentation/ImageViewer/ImageViewer.tsx | 41 -- app/views/AttachmentView.tsx | 5 +- app/views/ShareView/Preview.tsx | 3 +- 8 files changed, 128 insertions(+), 432 deletions(-) rename app/{presentation => containers}/ImageViewer/ImageComponent.ts (100%) create mode 100644 app/containers/ImageViewer/ImageViewer.tsx rename app/{presentation => containers}/ImageViewer/index.ts (100%) rename app/{presentation => containers}/ImageViewer/types.ts (100%) delete mode 100644 app/presentation/ImageViewer/ImageViewer.android.tsx delete mode 100644 app/presentation/ImageViewer/ImageViewer.tsx diff --git a/app/presentation/ImageViewer/ImageComponent.ts b/app/containers/ImageViewer/ImageComponent.ts similarity index 100% rename from app/presentation/ImageViewer/ImageComponent.ts rename to app/containers/ImageViewer/ImageComponent.ts diff --git a/app/containers/ImageViewer/ImageViewer.tsx b/app/containers/ImageViewer/ImageViewer.tsx new file mode 100644 index 00000000..c378b9e1 --- /dev/null +++ b/app/containers/ImageViewer/ImageViewer.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import { LayoutChangeEvent, StyleSheet, StyleProp, ViewStyle, ImageStyle, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { withTiming, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; + +import { useTheme } from '../../theme'; +import { ImageComponent } from './ImageComponent'; + +interface ImageViewerProps { + style?: StyleProp; + containerStyle?: StyleProp; + imageContainerStyle?: StyleProp; + + uri: string; + imageComponentType?: string; + width: number; + height: number; + onLoadEnd?: () => void; +} + +const styles = StyleSheet.create({ + flex: { + flex: 1 + }, + image: { + flex: 1 + } +}); + +export const ImageViewer = ({ uri = '', imageComponentType, width, height, ...props }: ImageViewerProps): React.ReactElement => { + const [centerX, setCenterX] = useState(0); + const [centerY, setCenterY] = useState(0); + + const onLayout = ({ + nativeEvent: { + layout: { x, y, width, height } + } + }: LayoutChangeEvent) => { + setCenterX(x + width / 2); + setCenterY(y + height / 2); + }; + + const translationX = useSharedValue(0); + const translationY = useSharedValue(0); + const offsetX = useSharedValue(0); + const offsetY = useSharedValue(0); + const scale = useSharedValue(1); + const scaleOffset = useSharedValue(1); + + const style = useAnimatedStyle(() => ({ + transform: [{ translateX: translationX.value }, { translateY: translationY.value }, { scale: scale.value }] + })); + + const resetScaleAnimation = () => { + scaleOffset.value = 1; + offsetX.value = 0; + offsetY.value = 0; + scale.value = withSpring(1); + translationX.value = withSpring(0, { overshootClamping: true }); + translationY.value = withSpring(0, { overshootClamping: true }); + }; + + const clamp = (value: number, min: number, max: number) => Math.max(Math.min(value, max), min); + + const pinchGesture = Gesture.Pinch() + .onUpdate(event => { + scale.value = clamp(scaleOffset.value * (event.scale > 0 ? event.scale : 1), 1, 4); + }) + .onEnd(() => { + scaleOffset.value = scale.value > 0 ? scale.value : 1; + }); + + const panGesture = Gesture.Pan() + .maxPointers(2) + .onStart(() => { + translationX.value = offsetX.value; + translationY.value = offsetY.value; + }) + .onUpdate(event => { + const scaleFactor = scale.value - 1; + translationX.value = clamp(event.translationX + offsetX.value, -scaleFactor * centerX, scaleFactor * centerX); + translationY.value = clamp(event.translationY + offsetY.value, -scaleFactor * centerY, scaleFactor * centerY); + }) + .onEnd(() => { + offsetX.value = translationX.value; + offsetY.value = translationY.value; + if (scale.value === 1) resetScaleAnimation(); + }); + + const doubleTapGesture = Gesture.Tap() + .numberOfTaps(2) + .maxDelay(120) + .maxDistance(70) + .onEnd(event => { + if (scaleOffset.value > 1) resetScaleAnimation(); + else { + scale.value = withTiming(2, { duration: 200 }); + translationX.value = withTiming(centerX - event.x, { duration: 200 }); + offsetX.value = centerX - event.x; + scaleOffset.value = 2; + } + }); + + const gesture = Gesture.Simultaneous(pinchGesture, panGesture, doubleTapGesture); + + const Component = ImageComponent(imageComponentType); + + const { colors } = useTheme(); + + return ( + + + + + + + + ); +}; diff --git a/app/presentation/ImageViewer/index.ts b/app/containers/ImageViewer/index.ts similarity index 100% rename from app/presentation/ImageViewer/index.ts rename to app/containers/ImageViewer/index.ts diff --git a/app/presentation/ImageViewer/types.ts b/app/containers/ImageViewer/types.ts similarity index 100% rename from app/presentation/ImageViewer/types.ts rename to app/containers/ImageViewer/types.ts diff --git a/app/presentation/ImageViewer/ImageViewer.android.tsx b/app/presentation/ImageViewer/ImageViewer.android.tsx deleted file mode 100644 index 8fbe4233..00000000 --- a/app/presentation/ImageViewer/ImageViewer.android.tsx +++ /dev/null @@ -1,386 +0,0 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; -import { PanGestureHandler, PinchGestureHandler, State } from 'react-native-gesture-handler'; -import Animated, { EasingNode } from 'react-native-reanimated'; - -import { ImageComponent } from './ImageComponent'; -import { themes } from '../../lib/constants'; -import { TSupportedThemes } from '../../theme'; - -const styles = StyleSheet.create({ - flex: { - flex: 1 - }, - image: { - backgroundColor: 'transparent' - } -}); - -const { - set, - cond, - eq, - or, - add, - sub, - min, - max, - multiply, - divide, - lessThan, - decay, - timing, - diff, - not, - abs, - startClock, - stopClock, - clockRunning, - Value, - Clock, - event -} = Animated; - -function scaleDiff(value: any) { - const tmp = new Value(1); - const prev = new Value(1); - return [set(tmp, divide(value, prev)), set(prev, value), tmp]; -} - -function dragDiff(value: any, updating: any) { - const tmp = new Value(0); - const prev = new Value(0); - return cond(updating, [set(tmp, sub(value, prev)), set(prev, value), tmp], set(prev, 0)); -} - -// returns linear friction coeff. When `value` is 0 coeff is 1 (no friction), then -// it grows linearly until it reaches `MAX_FRICTION` when `value` is equal -// to `MAX_VALUE` -function friction(value: any) { - const MAX_FRICTION = 5; - const MAX_VALUE = 100; - return max(1, min(MAX_FRICTION, add(1, multiply(value, (MAX_FRICTION - 1) / MAX_VALUE)))); -} - -function speed(value: any) { - const clock = new Clock(); - const dt = diff(clock); - return cond(lessThan(dt, 1), 0, multiply(1000, divide(diff(value), dt))); -} - -const MIN_SCALE = 1; -const MAX_SCALE = 2; - -function scaleRest(value: any) { - return cond(lessThan(value, MIN_SCALE), MIN_SCALE, cond(lessThan(MAX_SCALE, value), MAX_SCALE, value)); -} - -function scaleFriction(value: any, rest: any, delta: any) { - const MAX_FRICTION = 20; - const MAX_VALUE = 0.5; - const res = multiply(value, delta); - const howFar = abs(sub(rest, value)); - const f = max(1, min(MAX_FRICTION, add(1, multiply(howFar, (MAX_FRICTION - 1) / MAX_VALUE)))); - return cond(lessThan(0, howFar), multiply(value, add(1, divide(add(delta, -1), f))), res); -} - -function runTiming(clock: any, value: any, dest: any, startStopClock: any = true) { - const state = { - finished: new Value(0), - position: new Value(0), - frameTime: new Value(0), - time: new Value(0) - }; - - const config = { - toValue: new Value(0), - duration: 300, - easing: EasingNode.inOut(EasingNode.cubic) - }; - - return [ - cond(clockRunning(clock), 0, [ - set(state.finished, 0), - set(state.frameTime, 0), - set(state.time, 0), - set(state.position, value), - set(config.toValue, dest), - startStopClock && startClock(clock) - ]), - timing(clock, state, config), - cond(state.finished, startStopClock && stopClock(clock)), - state.position - ]; -} - -function runDecay(clock: any, value: any, velocity: any) { - const state = { - finished: new Value(0), - velocity: new Value(0), - position: new Value(0), - time: new Value(0) - }; - - const config = { deceleration: 0.99 }; - - return [ - cond(clockRunning(clock), 0, [ - set(state.finished, 0), - set(state.velocity, velocity), - set(state.position, value), - set(state.time, 0), - startClock(clock) - ]), - set(state.position, value), - decay(clock, state, config), - cond(state.finished, stopClock(clock)), - state.position - ]; -} - -function bouncyPinch( - value: any, - gesture: any, - gestureActive: any, - focalX: any, - displacementX: any, - focalY: any, - displacementY: any -) { - const clock = new Clock(); - - const delta = scaleDiff(gesture); - const rest = scaleRest(value); - const focalXRest = cond(lessThan(value, 1), 0, sub(displacementX, multiply(focalX, add(-1, divide(rest, value))))); - const focalYRest = cond(lessThan(value, 1), 0, sub(displacementY, multiply(focalY, add(-1, divide(rest, value))))); - const nextScale = new Value(1); - - return cond( - [delta, gestureActive], - [ - stopClock(clock), - set(nextScale, scaleFriction(value, rest, delta)), - set(displacementX, sub(displacementX, multiply(focalX, add(-1, divide(nextScale, value))))), - set(displacementY, sub(displacementY, multiply(focalY, add(-1, divide(nextScale, value))))), - nextScale - ], - cond( - or(clockRunning(clock), not(eq(rest, value))), - [ - set(displacementX, runTiming(clock, displacementX, focalXRest, false)), - set(displacementY, runTiming(clock, displacementY, focalYRest, false)), - runTiming(clock, value, rest) - ], - value - ) - ); -} - -function bouncy(value: any, gestureDiv: any, gestureActive: any, lowerBound: any, upperBound: any, f: any) { - const timingClock = new Clock(); - const decayClock = new Clock(); - - const velocity = speed(value); - - // did value go beyond the limits (lower, upper) - const isOutOfBounds = or(lessThan(value, lowerBound), lessThan(upperBound, value)); - // position to snap to (upper or lower is beyond or the current value elsewhere) - const rest = cond(lessThan(value, lowerBound), lowerBound, cond(lessThan(upperBound, value), upperBound, value)); - // how much the value exceeds the bounds, this is used to calculate friction - const outOfBounds = abs(sub(rest, value)); - - return cond( - [gestureDiv, velocity, gestureActive], - [stopClock(timingClock), stopClock(decayClock), add(value, divide(gestureDiv, f(outOfBounds)))], - cond( - or(clockRunning(timingClock), isOutOfBounds), - [stopClock(decayClock), runTiming(timingClock, value, rest)], - cond(or(clockRunning(decayClock), lessThan(5, abs(velocity))), runDecay(decayClock, value, velocity), value) - ) - ); -} - -const WIDTH = 300; -const HEIGHT = 300; - -interface IImageProps { - imageComponentType: string; -} - -class Image extends React.PureComponent { - render() { - const { imageComponentType }: any = this.props; - - const Component = ImageComponent(imageComponentType); - - return ; - } -} - -// https://github.com/software-mansion/react-native-reanimated/issues/1717 -const AnimatedImage: any = Animated.createAnimatedComponent(Image); - -// it was picked from https://github.com/software-mansion/react-native-reanimated/tree/master/Example/imageViewer -// and changed to use FastImage animated component - -interface IImageViewerProps { - uri: string; - width: number; - height: number; - theme: TSupportedThemes; - imageComponentType: string; -} - -export class ImageViewer extends React.Component { - private _onPinchEvent: any; - - private _focalDisplacementX: any; - - private _focalDisplacementY: any; - - private _scale: any; - - private _onPanEvent: any; - - private _panTransX: any; - - private _panTransY: any; - - constructor(props: IImageViewerProps) { - super(props); - - // DECLARE TRANSX - const panTransX = new Value(0); - const panTransY = new Value(0); - - // PINCH - const pinchScale = new Value(1); - const pinchFocalX = new Value(0); - const pinchFocalY = new Value(0); - const pinchState = new Value(-1); - - this._onPinchEvent = event([ - { - nativeEvent: { - state: pinchState, - scale: pinchScale, - focalX: pinchFocalX, - focalY: pinchFocalY - } - } - ]); - - // SCALE - const scale = new Value(1); - const pinchActive = eq(pinchState, State.ACTIVE); - this._focalDisplacementX = new Value(0); - const relativeFocalX = sub(pinchFocalX, add(panTransX, this._focalDisplacementX)); - this._focalDisplacementY = new Value(0); - const relativeFocalY = sub(pinchFocalY, add(panTransY, this._focalDisplacementY)); - this._scale = set( - scale, - bouncyPinch( - scale, - pinchScale, - pinchActive, - relativeFocalX, - this._focalDisplacementX, - relativeFocalY, - this._focalDisplacementY - ) - ); - - // PAN - const dragX = new Value(0); - const dragY = new Value(0); - const panState = new Value(-1); - this._onPanEvent = event([ - { - nativeEvent: { - translationX: dragX, - translationY: dragY, - state: panState - } - } - ]); - - const panActive = eq(panState, State.ACTIVE); - const panFriction = (value: any) => friction(value); - - // X - const panUpX = cond(lessThan(this._scale, 1), 0, multiply(-1, this._focalDisplacementX)); - const panLowX = add(panUpX, multiply(-WIDTH, add(max(1, this._scale), -1))); - this._panTransX = set( - panTransX, - bouncy(panTransX, dragDiff(dragX, panActive), or(panActive, pinchActive), panLowX, panUpX, panFriction) - ); - - // Y - const panUpY = cond(lessThan(this._scale, 1), 0, multiply(-1, this._focalDisplacementY)); - const panLowY = add(panUpY, multiply(-HEIGHT, add(max(1, this._scale), -1))); - this._panTransY = set( - panTransY, - bouncy(panTransY, dragDiff(dragY, panActive), or(panActive, pinchActive), panLowY, panUpY, panFriction) - ); - } - - pinchRef = React.createRef(); - - panRef = React.createRef(); - - render() { - const { uri, width, height, imageComponentType, theme, ...props } = this.props; - - // The below two animated values makes it so that scale appears to be done - // from the top left corner of the image view instead of its center. This - // is required for the "scale focal point" math to work correctly - const scaleTopLeftFixX = divide(multiply(WIDTH, add(this._scale, -1)), 2); - const scaleTopLeftFixY = divide(multiply(HEIGHT, add(this._scale, -1)), 2); - const backgroundColor = themes[theme].previewBackground; - - return ( - - - - - - - - - - ); - } -} diff --git a/app/presentation/ImageViewer/ImageViewer.tsx b/app/presentation/ImageViewer/ImageViewer.tsx deleted file mode 100644 index c64b0cce..00000000 --- a/app/presentation/ImageViewer/ImageViewer.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { ScrollView, StyleSheet } from 'react-native'; - -import { ImageComponent } from './ImageComponent'; -import { themes } from '../../lib/constants'; -import { TSupportedThemes } from '../../theme'; - -const styles = StyleSheet.create({ - scrollContent: { - width: '100%', - height: '100%' - }, - image: { - flex: 1 - } -}); - -interface IImageViewer { - uri: string; - imageComponentType?: string; - width: number; - height: number; - theme: TSupportedThemes; - onLoadEnd?: () => void; -} - -export const ImageViewer = ({ uri, imageComponentType, theme, width, height, ...props }: IImageViewer): JSX.Element => { - const backgroundColor = themes[theme].previewBackground; - const Component = ImageComponent(imageComponentType); - return ( - // @ts-ignore - - - - ); -}; diff --git a/app/views/AttachmentView.tsx b/app/views/AttachmentView.tsx index b8bfcdb9..8ea23bed 100644 --- a/app/views/AttachmentView.tsx +++ b/app/views/AttachmentView.tsx @@ -14,7 +14,7 @@ import { LISTENER } from '../containers/Toast'; import EventEmitter from '../lib/methods/helpers/events'; import I18n from '../i18n'; import { TSupportedThemes, withTheme } from '../theme'; -import { ImageViewer } from '../presentation/ImageViewer'; +import { ImageViewer } from '../containers/ImageViewer'; import { themes } from '../lib/constants'; import RCActivityIndicator from '../containers/ActivityIndicator'; import * as HeaderButton from '../containers/HeaderButton'; @@ -146,13 +146,12 @@ class AttachmentView extends React.Component { - const { theme, width, height, insets } = this.props; + const { width, height, insets } = this.props; const headerHeight = getHeaderHeight(width > height); return ( this.setState({ loading: false })} - theme={theme} width={width} height={height - insets.top - insets.bottom - headerHeight} /> diff --git a/app/views/ShareView/Preview.tsx b/app/views/ShareView/Preview.tsx index 50dc7402..23493949 100644 --- a/app/views/ShareView/Preview.tsx +++ b/app/views/ShareView/Preview.tsx @@ -5,7 +5,7 @@ import { ScrollView, StyleSheet, Text } from 'react-native'; import prettyBytes from 'pretty-bytes'; import { CustomIcon, TIconsName } from '../../containers/CustomIcon'; -import { ImageViewer, types } from '../../presentation/ImageViewer'; +import { ImageViewer, types } from '../../containers/ImageViewer'; import { useDimensions, useOrientation } from '../../dimensions'; import { getHeaderHeight } from '../../containers/Header'; import sharedStyles from '../Styles'; @@ -100,7 +100,6 @@ const Preview = React.memo(({ item, theme, isShareExtension, length }: IPreview) imageComponentType={isShareExtension ? types.REACT_NATIVE_IMAGE : types.FAST_IMAGE} width={width} height={calculatedHeight} - theme={theme} /> ); }