diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 4988c33ed8ce..46ce4cf63f26 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -501,7 +501,6 @@ function AttachmentModal(props) { report={props.report} onNavigate={onNavigate} source={props.source} - onClose={closeModal} onToggleKeyboard={updateConfirmButtonVisibility} setDownloadButtonVisibility={setDownloadButtonVisibility} /> diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js deleted file mode 100644 index abaf06900853..000000000000 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js +++ /dev/null @@ -1,5 +0,0 @@ -import {createContext} from 'react'; - -const AttachmentCarouselPagerContext = createContext(null); - -export default AttachmentCarouselPagerContext; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts new file mode 100644 index 000000000000..270e0b04909c --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts @@ -0,0 +1,17 @@ +import type {ForwardedRef} from 'react'; +import {createContext} from 'react'; +import type PagerView from 'react-native-pager-view'; +import type {SharedValue} from 'react-native-reanimated'; + +type AttachmentCarouselPagerContextValue = { + pagerRef: ForwardedRef; + isPagerScrolling: SharedValue; + isScrollEnabled: SharedValue; + onTap: () => void; + onScaleChanged: (scale: number) => void; +}; + +const AttachmentCarouselPagerContext = createContext(null); + +export default AttachmentCarouselPagerContext; +export type {AttachmentCarouselPagerContextValue}; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js deleted file mode 100644 index 553e963a3461..000000000000 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.js +++ /dev/null @@ -1,172 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; -import {createNativeWrapper} from 'react-native-gesture-handler'; -import PagerView from 'react-native-pager-view'; -import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useEvent, useHandler, useSharedValue} from 'react-native-reanimated'; -import _ from 'underscore'; -import refPropTypes from '@components/refPropTypes'; -import useThemeStyles from '@hooks/useThemeStyles'; -import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext'; - -const AnimatedPagerView = Animated.createAnimatedComponent(createNativeWrapper(PagerView)); - -function usePageScrollHandler(handlers, dependencies) { - const {context, doDependenciesDiffer} = useHandler(handlers, dependencies); - const subscribeForEvents = ['onPageScroll']; - - return useEvent( - (event) => { - 'worklet'; - - const {onPageScroll} = handlers; - if (onPageScroll && event.eventName.endsWith('onPageScroll')) { - onPageScroll(event, context); - } - }, - subscribeForEvents, - doDependenciesDiffer, - ); -} - -const noopWorklet = () => { - 'worklet'; - - // noop -}; - -const pagerPropTypes = { - items: PropTypes.arrayOf( - PropTypes.shape({ - key: PropTypes.string, - url: PropTypes.string, - }), - ).isRequired, - renderItem: PropTypes.func.isRequired, - initialIndex: PropTypes.number, - onPageSelected: PropTypes.func, - onTap: PropTypes.func, - onSwipe: PropTypes.func, - onSwipeSuccess: PropTypes.func, - onSwipeDown: PropTypes.func, - onPinchGestureChange: PropTypes.func, - forwardedRef: refPropTypes, -}; - -const pagerDefaultProps = { - initialIndex: 0, - onPageSelected: () => {}, - onTap: () => {}, - onSwipe: noopWorklet, - onSwipeSuccess: () => {}, - onSwipeDown: () => {}, - onPinchGestureChange: () => {}, - forwardedRef: null, -}; - -function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelected, onTap, onSwipe = noopWorklet, onSwipeSuccess, onSwipeDown, onPinchGestureChange, forwardedRef}) { - const styles = useThemeStyles(); - const shouldPagerScroll = useSharedValue(true); - const pagerRef = useRef(null); - - const isScrolling = useSharedValue(false); - const activeIndex = useSharedValue(initialIndex); - - const pageScrollHandler = usePageScrollHandler( - { - onPageScroll: (e) => { - 'worklet'; - - activeIndex.value = e.position; - isScrolling.value = e.offset !== 0; - }, - }, - [], - ); - - const [activePage, setActivePage] = useState(initialIndex); - - useEffect(() => { - setActivePage(initialIndex); - activeIndex.value = initialIndex; - }, [activeIndex, initialIndex]); - - // we use reanimated for this since onPageSelected is called - // in the middle of the pager animation - useAnimatedReaction( - () => isScrolling.value, - (stillScrolling) => { - if (stillScrolling) { - return; - } - - runOnJS(setActivePage)(activeIndex.value); - }, - ); - - useImperativeHandle( - forwardedRef, - () => ({ - setPage: (...props) => pagerRef.current.setPage(...props), - }), - [], - ); - - const animatedProps = useAnimatedProps(() => ({ - scrollEnabled: shouldPagerScroll.value, - })); - - const contextValue = useMemo( - () => ({ - isScrolling, - pagerRef, - shouldPagerScroll, - onPinchGestureChange, - onTap, - onSwipe, - onSwipeSuccess, - onSwipeDown, - }), - [isScrolling, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeSuccess, onSwipeDown], - ); - - return ( - - - {_.map(items, (item, index) => ( - - {renderItem({item, index, isActive: index === activePage})} - - ))} - - - ); -} - -AttachmentCarouselPager.propTypes = pagerPropTypes; -AttachmentCarouselPager.defaultProps = pagerDefaultProps; -AttachmentCarouselPager.displayName = 'AttachmentCarouselPager'; - -const AttachmentCarouselPagerWithRef = React.forwardRef((props, ref) => ( - -)); - -AttachmentCarouselPagerWithRef.displayName = 'AttachmentCarouselPagerWithRef'; - -export default AttachmentCarouselPagerWithRef; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx new file mode 100644 index 000000000000..490afb6614ac --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -0,0 +1,150 @@ +import type {ForwardedRef} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {NativeViewGestureHandlerProps} from 'react-native-gesture-handler'; +import {createNativeWrapper} from 'react-native-gesture-handler'; +import type {PagerViewProps} from 'react-native-pager-view'; +import PagerView from 'react-native-pager-view'; +import Animated, {useAnimatedProps, useSharedValue} from 'react-native-reanimated'; +import useThemeStyles from '@hooks/useThemeStyles'; +import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext'; +import usePageScrollHandler from './usePageScrollHandler'; + +const WrappedPagerView = createNativeWrapper(PagerView) as React.ForwardRefExoticComponent< + PagerViewProps & NativeViewGestureHandlerProps & React.RefAttributes> +>; +const AnimatedPagerView = Animated.createAnimatedComponent(WrappedPagerView); + +type AttachmentCarouselPagerHandle = { + setPage: (selectedPage: number) => void; +}; + +type PagerItem = { + key: string; + url: string; + source: string; +}; + +type AttachmentCarouselPagerProps = { + items: PagerItem[]; + renderItem: (props: {item: PagerItem; index: number; isActive: boolean}) => React.ReactNode; + initialIndex: number; + onPageSelected: () => void; + onRequestToggleArrows: (showArrows?: boolean) => void; +}; + +function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelected, onRequestToggleArrows}: AttachmentCarouselPagerProps, ref: ForwardedRef) { + const styles = useThemeStyles(); + const pagerRef = useRef(null); + + const scale = useRef(1); + const isPagerScrolling = useSharedValue(false); + const isScrollEnabled = useSharedValue(true); + + const activePage = useSharedValue(initialIndex); + const [activePageState, setActivePageState] = useState(initialIndex); + + const pageScrollHandler = usePageScrollHandler((e) => { + 'worklet'; + + activePage.value = e.position; + isPagerScrolling.value = e.offset !== 0; + }, []); + + useEffect(() => { + setActivePageState(initialIndex); + activePage.value = initialIndex; + }, [activePage, initialIndex]); + + /** + * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. + * It is used to react to zooming/pinching and (mostly) enabling/disabling scrolling on the pager, + * as well as enabling/disabling the carousel buttons. + */ + const handleScaleChange = useCallback( + (newScale: number) => { + if (newScale === scale.current) { + return; + } + + scale.current = newScale; + + const newIsScrollEnabled = newScale === 1; + if (isScrollEnabled.value === newIsScrollEnabled) { + return; + } + + isScrollEnabled.value = newIsScrollEnabled; + onRequestToggleArrows(newIsScrollEnabled); + }, + [isScrollEnabled, onRequestToggleArrows], + ); + + /** + * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. + * It is used to trigger touch events on the pager when the user taps on the MultiGestureCanvas/Lightbox. + */ + const handleTap = useCallback(() => { + if (!isScrollEnabled.value) { + return; + } + + onRequestToggleArrows(); + }, [isScrollEnabled.value, onRequestToggleArrows]); + + const contextValue = useMemo( + () => ({ + pagerRef, + isPagerScrolling, + isScrollEnabled, + onTap: handleTap, + onScaleChanged: handleScaleChange, + }), + [isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange], + ); + + const animatedProps = useAnimatedProps(() => ({ + scrollEnabled: isScrollEnabled.value, + })); + + /** + * This "useImperativeHandle" call is needed to expose certain imperative methods via the pager's ref. + * setPage: can be used to programmatically change the page from a parent component + */ + useImperativeHandle( + ref, + () => ({ + setPage: (selectedPage) => { + pagerRef.current?.setPage(selectedPage); + }, + }), + [], + ); + + return ( + + + {items.map((item, index) => ( + + {renderItem({item, index, isActive: index === activePageState})} + + ))} + + + ); +} +AttachmentCarouselPager.displayName = 'AttachmentCarouselPager'; + +export default React.forwardRef(AttachmentCarouselPager); diff --git a/src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts b/src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts new file mode 100644 index 000000000000..ab7f0d99b7f0 --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts @@ -0,0 +1,41 @@ +import type {PagerViewProps} from 'react-native-pager-view'; +import {useEvent, useHandler} from 'react-native-reanimated'; + +type PageScrollHandler = NonNullable; + +type PageScrollEventData = Parameters[0]['nativeEvent']; +type PageScrollContext = Record; + +// Reanimated doesn't expose the type for animated event handlers, therefore we must infer it from the useHandler hook. +// The AnimatedPageScrollHandler type is the type of the onPageScroll prop from react-native-pager-view as an animated handler. +type AnimatedHandlers = Parameters>[0]; +type AnimatedPageScrollHandler = AnimatedHandlers[string]; + +type Dependencies = Parameters[1]; + +/** + * This hook is used to create a wrapped handler for the onPageScroll event from react-native-pager-view. + * The produced handler can react to the onPageScroll event and allows to use it with animated shared values (from REA) + * This hook is a wrapper around the useHandler and useEvent hooks from react-native-reanimated. + * @param onPageScroll The handler for the onPageScroll event from react-native-pager-view + * @param dependencies The dependencies for the useHandler hook + * @returns A wrapped/animated handler for the onPageScroll event from react-native-pager-view + */ +const usePageScrollHandler = (onPageScroll: AnimatedPageScrollHandler, dependencies: Dependencies): PageScrollHandler => { + const {context, doDependenciesDiffer} = useHandler({onPageScroll}, dependencies); + const subscribeForEvents = ['onPageScroll']; + + return useEvent( + (event) => { + 'worklet'; + + if (onPageScroll && event.eventName.endsWith('onPageScroll')) { + onPageScroll(event, context); + } + }, + subscribeForEvents, + doDependenciesDiffer, + ); +}; + +export default usePageScrollHandler; diff --git a/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js b/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js index 72a554de68be..5aa665683162 100644 --- a/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js +++ b/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js @@ -10,9 +10,6 @@ const propTypes = { /** Callback to update the parent modal's state with a source and name from the attachments array */ onNavigate: PropTypes.func, - /** Callback to close carousel when user swipes down (on native) */ - onClose: PropTypes.func, - /** Function to change the download button Visibility */ setDownloadButtonVisibility: PropTypes.func, @@ -39,7 +36,6 @@ const defaultProps = { parentReportActions: {}, transaction: {}, onNavigate: () => {}, - onClose: () => {}, setDownloadButtonVisibility: () => {}, }; diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js index f5479b73abdb..fa24ccd0ef53 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.js +++ b/src/components/Attachments/AttachmentCarousel/index.native.js @@ -18,12 +18,11 @@ import extractAttachmentsFromReport from './extractAttachmentsFromReport'; import AttachmentCarouselPager from './Pager'; import useCarouselArrows from './useCarouselArrows'; -function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, translate, onClose}) { +function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, translate}) { const styles = useThemeStyles(); const pagerRef = useRef(null); const [page, setPage] = useState(); const [attachments, setAttachments] = useState([]); - const [isPinchGestureRunning, setIsPinchGestureRunning] = useState(true); const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows(); const [activeSource, setActiveSource] = useState(source); @@ -88,6 +87,22 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, [autoHideArrows, page, updatePage], ); + /** + * Toggles the arrows visibility + * @param {Boolean} showArrows if showArrows is passed, it will set the visibility to the passed value + */ + const toggleArrows = useCallback( + (showArrows) => { + if (showArrows === undefined) { + setShouldShowArrows((prevShouldShowArrows) => !prevShouldShowArrows); + return; + } + + setShouldShowArrows(showArrows); + }, + [setShouldShowArrows], + ); + /** * Defines how a single attachment should be rendered * @param {{ reportActionID: String, isAuthTokenRequired: Boolean, source: String, file: { name: String }, hasBeenFlagged: Boolean }} item @@ -101,18 +116,13 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, index={index} activeIndex={page} isFocused={isActive && activeSource === item.source} - onPress={() => setShouldShowArrows(!shouldShowArrows)} /> ), - [activeSource, attachments.length, page, setShouldShowArrows, shouldShowArrows], + [activeSource, attachments.length, page], ); return ( - setShouldShowArrows(true)} - onMouseLeave={() => setShouldShowArrows(false)} - > + {page == null ? ( ) : ( @@ -127,7 +137,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, ) : ( <> cycleThroughAttachments(-1)} @@ -140,14 +150,8 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, items={attachments} renderItem={renderItem} initialIndex={page} + onRequestToggleArrows={toggleArrows} onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)} - onPinchGestureChange={(newIsPinchGestureRunning) => { - setIsPinchGestureRunning(newIsPinchGestureRunning); - if (!newIsPinchGestureRunning && !shouldShowArrows) { - setShouldShowArrows(true); - } - }} - onSwipeDown={onClose} ref={pagerRef} /> diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js index f53b993f6053..14c60458b044 100755 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js @@ -13,7 +13,7 @@ const propTypes = { }; function AttachmentViewImage({ - source, + url, file, isAuthTokenRequired, isUsedInCarousel, @@ -25,15 +25,13 @@ function AttachmentViewImage({ onPress, onError, isImage, - onScaleChanged, translate, }) { const styles = useThemeStyles(); const children = ( { if (!attachmentCarouselPagerContext) { return; } - attachmentCarouselPagerContext.onPinchGestureChange(false); + attachmentCarouselPagerContext.onScaleChanged(1); // eslint-disable-next-line react-hooks/exhaustive-deps -- we just want to call this function when component is mounted }, []); + /** + * When the PDF's onScaleChanged event is triggered, we must call the context's onScaleChanged callback, + * because we want to disable the pager scroll when the pdf is zoomed in, + * as well as call the onScaleChanged prop of the AttachmentViewPdf component if defined. + */ const onScaleChanged = useCallback( - (scale) => { - onScaleChangedProp(scale); + (newScale) => { + if (onScaleChangedProp !== undefined) { + onScaleChangedProp(newScale); + } // When a pdf is shown in a carousel, we want to disable the pager scroll when the pdf is zoomed in if (isUsedInCarousel && attachmentCarouselPagerContext) { - const shouldPagerScroll = scale === 1; - - attachmentCarouselPagerContext.onPinchGestureChange(!shouldPagerScroll); + attachmentCarouselPagerContext.onScaleChanged(newScale); + } + }, + [attachmentCarouselPagerContext, isUsedInCarousel, onScaleChangedProp], + ); - if (attachmentCarouselPagerContext.shouldPagerScroll.value === shouldPagerScroll) { - return; - } + /** + * This callback is used to pass-through the onPress event from the AttachmentViewPdf's props + * as well trigger the onTap event from the context. + * The onTap event should only be triggered, if the pager is currently scrollable. + * Otherwise it means that the PDF is currently zoomed in, therefore the onTap callback should be ignored + */ + const onPress = useCallback( + (e) => { + if (onPressProp !== undefined) { + onPressProp(e); + } - attachmentCarouselPagerContext.shouldPagerScroll.value = shouldPagerScroll; + if (attachmentCarouselPagerContext !== null && isScrollEnabled.value) { + attachmentCarouselPagerContext.onTap(e); } }, - [attachmentCarouselPagerContext, isUsedInCarousel, onScaleChangedProp], + [attachmentCarouselPagerContext, isScrollEnabled, onPressProp], ); return ( @@ -60,8 +93,8 @@ function BaseAttachmentViewPdf({ ); } -BaseAttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; -BaseAttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; +BaseAttachmentViewPdf.propTypes = baseAttachmentViewPdfPropTypes; +BaseAttachmentViewPdf.defaultProps = baseAttachmentViewPdfDefaultProps; BaseAttachmentViewPdf.displayName = 'BaseAttachmentViewPdf'; export default memo(BaseAttachmentViewPdf); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js index db4f4f11d68c..07cd8ecf61e7 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js @@ -1,4 +1,4 @@ -import React, {memo, useCallback, useContext} from 'react'; +import React, {memo, useContext, useMemo} from 'react'; import {StyleSheet, View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import Animated, {useSharedValue} from 'react-native-reanimated'; @@ -7,13 +7,14 @@ import useThemeStyles from '@hooks/useThemeStyles'; import BaseAttachmentViewPdf from './BaseAttachmentViewPdf'; import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes'; +// If the user pans less than this threshold, we'll not enable/disable the pager scroll, since the thouch will most probably be a tap. +// If the user moves their finger more than this threshold in the X direction, we'll enable the pager scroll. Otherwise if in the Y direction, we'll disable it. +const SCROLL_THRESHOLD = 10; + function AttachmentViewPdf(props) { const styles = useThemeStyles(); - const {onScaleChanged, ...restProps} = props; const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); - const scaleRef = useSharedValue(1); - const offsetX = useSharedValue(0); - const offsetY = useSharedValue(0); + const scale = useSharedValue(1); // Reanimated freezes all objects captured in the closure of a worklet. // Since Reanimated 3, entire objects are captured instead of just the relevant properties. @@ -22,30 +23,53 @@ function AttachmentViewPdf(props) { // frozen, which combined with Reanimated using strict mode since 3.6.0 was resulting in errors. // Without strict mode, it would just silently fail. // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze#description - const shouldPagerScroll = attachmentCarouselPagerContext !== null ? attachmentCarouselPagerContext.shouldPagerScroll : undefined; + const isScrollEnabled = attachmentCarouselPagerContext === null ? undefined : attachmentCarouselPagerContext.isScrollEnabled; + + const offsetX = useSharedValue(0); + const offsetY = useSharedValue(0); + const isPanGestureActive = useSharedValue(false); const Pan = Gesture.Pan() .manualActivation(true) .onTouchesMove((evt) => { - if (offsetX.value !== 0 && offsetY.value !== 0 && shouldPagerScroll) { + if (offsetX.value !== 0 && offsetY.value !== 0 && isScrollEnabled) { + const translateX = Math.abs(evt.allTouches[0].absoluteX - offsetX.value); + const translateY = Math.abs(evt.allTouches[0].absoluteY - offsetY.value); + const allowEnablingScroll = !isPanGestureActive.value || isScrollEnabled.value; + // if the value of X is greater than Y and the pdf is not zoomed in, // enable the pager scroll so that the user // can swipe to the next attachment otherwise disable it. - if (Math.abs(evt.allTouches[0].absoluteX - offsetX.value) > Math.abs(evt.allTouches[0].absoluteY - offsetY.value) && scaleRef.value === 1) { - shouldPagerScroll.value = true; - } else { - shouldPagerScroll.value = false; + if (translateX > translateY && translateX > SCROLL_THRESHOLD && scale.value === 1 && allowEnablingScroll) { + isScrollEnabled.value = true; + } else if (translateY > SCROLL_THRESHOLD) { + isScrollEnabled.value = false; } } + + isPanGestureActive.value = true; offsetX.value = evt.allTouches[0].absoluteX; offsetY.value = evt.allTouches[0].absoluteY; + }) + .onTouchesUp(() => { + isPanGestureActive.value = false; + isScrollEnabled.value = true; }); - const updateScale = useCallback( - (scale) => { - scaleRef.value = scale; - }, - [scaleRef], + const Content = useMemo( + () => ( + { + // The react-native-pdf's onScaleChanged event will sometimes give us scale values of e.g. 0.99... instead of 1, + // even though we're not pinching/zooming + // Rounding the scale value to 2 decimal place fixes this issue, since pinching will still be possible but very small pinches are ignored. + scale.value = Math.round(newScale * 1e2) / 1e2; + }} + /> + ), + [props, scale], ); return ( @@ -53,21 +77,18 @@ function AttachmentViewPdf(props) { collapsable={false} style={styles.flex1} > - - - { - updateScale(scale); - onScaleChanged(); - }} - /> - - + {attachmentCarouselPagerContext === null ? ( + Content + ) : ( + + + {Content} + + + )} ); } diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js index c3d1423b17c9..d6a402613c34 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js @@ -2,7 +2,7 @@ import React, {memo} from 'react'; import PDFView from '@components/PDFView'; import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes'; -function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, onPress, onScaleChanged, onToggleKeyboard, onLoadComplete, errorLabelStyles, style}) { +function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, onPress, onToggleKeyboard, onLoadComplete, errorLabelStyles, style}) { return ( diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index b0060afdb813..67f6dd95568e 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -4,6 +4,7 @@ import React, {memo, useState} from 'react'; import {ActivityIndicator, ScrollView, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import * as AttachmentsPropTypes from '@components/Attachments/propTypes'; import DistanceEReceipt from '@components/DistanceEReceipt'; import EReceipt from '@components/EReceipt'; import Icon from '@components/Icon'; @@ -28,6 +29,9 @@ const propTypes = { ...attachmentViewPropTypes, ...withLocalizePropTypes, + /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ + source: AttachmentsPropTypes.attachmentSourcePropType.isRequired, + /** Flag to show/hide download icon */ shouldShowDownloadIcon: PropTypes.bool, @@ -67,7 +71,6 @@ function AttachmentView({ shouldShowLoadingSpinnerIcon, shouldShowDownloadIcon, containerStyles, - onScaleChanged, onToggleKeyboard, translate, isFocused, @@ -141,7 +144,6 @@ function AttachmentView({ carouselItemIndex={carouselItemIndex} carouselActiveItemIndex={carouselActiveItemIndex} onPress={onPress} - onScaleChanged={onScaleChanged} onToggleKeyboard={onToggleKeyboard} onLoadComplete={() => !loadComplete && setLoadComplete(true)} errorLabelStyles={isUsedInAttachmentModal ? [styles.textLabel, styles.textLarge] : [styles.cursorAuto]} @@ -163,7 +165,7 @@ function AttachmentView({ if (isImage || (file && Str.isImage(file.name))) { return ( { setImageError(true); }} diff --git a/src/components/Attachments/AttachmentView/propTypes.js b/src/components/Attachments/AttachmentView/propTypes.js index 286c903ccf5b..d78bed8526b8 100644 --- a/src/components/Attachments/AttachmentView/propTypes.js +++ b/src/components/Attachments/AttachmentView/propTypes.js @@ -5,9 +5,6 @@ const attachmentViewPropTypes = { /** Whether source url requires authentication */ isAuthTokenRequired: PropTypes.bool, - /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ - source: AttachmentsPropTypes.attachmentSourcePropType.isRequired, - /** File object can be an instance of File or Object */ file: AttachmentsPropTypes.attachmentFilePropType, diff --git a/src/components/ImageView/index.native.tsx b/src/components/ImageView/index.native.tsx index e36bb39d2bed..8de1946ef554 100644 --- a/src/components/ImageView/index.native.tsx +++ b/src/components/ImageView/index.native.tsx @@ -1,15 +1,13 @@ import React from 'react'; import Lightbox from '@components/Lightbox'; -import {zoomRangeDefaultProps} from '@components/MultiGestureCanvas/propTypes'; +import {DEFAULT_ZOOM_RANGE} from '@components/MultiGestureCanvas'; import type {ImageViewProps} from './types'; function ImageView({ isAuthTokenRequired = false, url, - onScaleChanged, - onPress, style, - zoomRange = zoomRangeDefaultProps.zoomRange, + zoomRange = DEFAULT_ZOOM_RANGE, onError, isUsedInCarousel = false, isSingleCarouselItem = false, @@ -20,11 +18,9 @@ function ImageView({ return ( void; - /** URL to full-sized image */ url: string; @@ -29,9 +26,6 @@ type ImageViewProps = { /** The index of the currently active carousel item */ carouselActiveItemIndex?: number; - /** Function for handle on press */ - onPress?: () => void; - /** Additional styles to add to the component */ style?: StyleProp; diff --git a/src/components/Lightbox.js b/src/components/Lightbox.js deleted file mode 100644 index 8b7d68befafd..000000000000 --- a/src/components/Lightbox.js +++ /dev/null @@ -1,239 +0,0 @@ -/* eslint-disable es/no-optional-chaining */ -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; -import useStyleUtils from '@hooks/useStyleUtils'; -import stylePropTypes from '@styles/stylePropTypes'; -import * as AttachmentsPropTypes from './Attachments/propTypes'; -import Image from './Image'; -import MultiGestureCanvas from './MultiGestureCanvas'; -import getCanvasFitScale from './MultiGestureCanvas/getCanvasFitScale'; -import {zoomRangeDefaultProps, zoomRangePropTypes} from './MultiGestureCanvas/propTypes'; - -// Increase/decrease this number to change the number of concurrent lightboxes -// The more concurrent lighboxes, the worse performance gets (especially on low-end devices) -// -1 means unlimited -const NUMBER_OF_CONCURRENT_LIGHTBOXES = 3; - -const cachedDimensions = new Map(); - -/** - * On the native layer, we use a image library to handle zoom functionality - */ -const propTypes = { - ...zoomRangePropTypes, - - /** Function for handle on press */ - onPress: PropTypes.func, - - /** Handles errors while displaying the image */ - onError: PropTypes.func, - - /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ - source: AttachmentsPropTypes.attachmentSourcePropType.isRequired, - - /** Whether source url requires authentication */ - isAuthTokenRequired: PropTypes.bool, - - /** Whether the Lightbox is used within a carousel component and there are other sibling elements */ - hasSiblingCarouselItems: PropTypes.bool, - - /** The index of the carousel item */ - index: PropTypes.number, - - /** The index of the currently active carousel item */ - activeIndex: PropTypes.number, - - /** Additional styles to add to the component */ - style: stylePropTypes, -}; - -const defaultProps = { - ...zoomRangeDefaultProps, - - isAuthTokenRequired: false, - index: 0, - activeIndex: 0, - hasSiblingCarouselItems: false, - onPress: () => {}, - onError: () => {}, - style: {}, -}; - -const DEFAULT_IMAGE_SIZE = 200; - -function Lightbox({isAuthTokenRequired, source, onScaleChanged, onPress, onError, style, index, activeIndex, hasSiblingCarouselItems, zoomRange}) { - const StyleUtils = useStyleUtils(); - - const [containerSize, setContainerSize] = useState({width: 0, height: 0}); - const isContainerLoaded = containerSize.width !== 0 && containerSize.height !== 0; - - const [imageDimensions, _setImageDimensions] = useState(() => cachedDimensions.get(source)); - const setImageDimensions = (newDimensions) => { - _setImageDimensions(newDimensions); - cachedDimensions.set(source, newDimensions); - }; - - const isItemActive = index === activeIndex; - const [isActive, setActive] = useState(isItemActive); - const [isImageLoaded, setImageLoaded] = useState(false); - - const isInactiveCarouselItem = hasSiblingCarouselItems && !isActive; - const [isFallbackVisible, setFallbackVisible] = useState(isInactiveCarouselItem); - const [isFallbackLoaded, setFallbackLoaded] = useState(false); - - const isLightboxLoaded = imageDimensions?.lightboxSize != null; - const isLightboxInRange = useMemo(() => { - if (NUMBER_OF_CONCURRENT_LIGHTBOXES === -1) { - return true; - } - - const indexCanvasOffset = Math.floor((NUMBER_OF_CONCURRENT_LIGHTBOXES - 1) / 2) || 0; - const indexOutOfRange = index > activeIndex + indexCanvasOffset || index < activeIndex - indexCanvasOffset; - return !indexOutOfRange; - }, [activeIndex, index]); - const isLightboxVisible = isLightboxInRange && (isActive || isLightboxLoaded || isFallbackLoaded); - - const isLoading = isActive && (!isContainerLoaded || !isImageLoaded); - - const updateCanvasSize = useCallback( - ({nativeEvent}) => setContainerSize({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)}), - [], - ); - - // We delay setting a page to active state by a (few) millisecond(s), - // to prevent the image transformer from flashing while still rendering - // Instead, we show the fallback image while the image transformer is loading the image - useEffect(() => { - if (isItemActive) { - setTimeout(() => setActive(true), 1); - } else { - setActive(false); - } - }, [isItemActive]); - - useEffect(() => { - if (isLightboxVisible) { - return; - } - setImageLoaded(false); - }, [isLightboxVisible]); - - useEffect(() => { - if (!hasSiblingCarouselItems) { - return; - } - - if (isActive) { - if (isImageLoaded && isFallbackVisible) { - // We delay hiding the fallback image while image transformer is still rendering - setTimeout(() => { - setFallbackVisible(false); - setFallbackLoaded(false); - }, 100); - } - } else { - if (isLightboxVisible && isLightboxLoaded) { - return; - } - - // Show fallback when the image goes out of focus or when the image is loading - setFallbackVisible(true); - } - }, [hasSiblingCarouselItems, isActive, isImageLoaded, isFallbackVisible, isLightboxLoaded, isLightboxVisible]); - - const fallbackSize = useMemo(() => { - if (!hasSiblingCarouselItems || (imageDimensions?.lightboxSize == null && imageDimensions?.fallbackSize == null) || containerSize.width === 0 || containerSize.height === 0) { - return { - width: DEFAULT_IMAGE_SIZE, - height: DEFAULT_IMAGE_SIZE, - }; - } - - const imageSize = imageDimensions.lightboxSize || imageDimensions.fallbackSize; - - const {minScale} = getCanvasFitScale({canvasSize: containerSize, contentSize: imageSize}); - - return { - width: PixelRatio.roundToNearestPixel(imageSize.width * minScale), - height: PixelRatio.roundToNearestPixel(imageSize.height * minScale), - }; - }, [containerSize, hasSiblingCarouselItems, imageDimensions]); - - return ( - - {isContainerLoaded && ( - <> - {isLightboxVisible && ( - - - setImageLoaded(true)} - onLoad={(e) => { - const width = (e.nativeEvent?.width || 0) * PixelRatio.get(); - const height = (e.nativeEvent?.height || 0) * PixelRatio.get(); - setImageDimensions({...imageDimensions, lightboxSize: {width, height}}); - }} - /> - - - )} - - {/* Keep rendering the image without gestures as fallback if the carousel item is not active and while the lightbox is loading the image */} - {isFallbackVisible && ( - - setFallbackLoaded(true)} - onLoad={(e) => { - const width = (e.nativeEvent?.width || 0) * PixelRatio.get(); - const height = (e.nativeEvent?.height || 0) * PixelRatio.get(); - - if (imageDimensions?.lightboxSize != null) { - return; - } - - setImageDimensions({...imageDimensions, fallbackSize: {width, height}}); - }} - /> - - )} - - {/* Show activity indicator while the lightbox is still loading the image. */} - {isLoading && ( - - )} - - )} - - ); -} - -Lightbox.propTypes = propTypes; -Lightbox.defaultProps = defaultProps; -Lightbox.displayName = 'Lightbox'; - -export default Lightbox; diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx new file mode 100644 index 000000000000..aeec1876eb93 --- /dev/null +++ b/src/components/Lightbox/index.tsx @@ -0,0 +1,222 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import type {LayoutChangeEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; +import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; +import Image from '@components/Image'; +import MultiGestureCanvas, {DEFAULT_ZOOM_RANGE} from '@components/MultiGestureCanvas'; +import type {CanvasSize, ContentSize, OnScaleChangedCallback, ZoomRange} from '@components/MultiGestureCanvas/types'; +import {getCanvasFitScale} from '@components/MultiGestureCanvas/utils'; +import useStyleUtils from '@hooks/useStyleUtils'; +import NUMBER_OF_CONCURRENT_LIGHTBOXES from './numberOfConcurrentLightboxes'; + +const DEFAULT_IMAGE_SIZE = 200; +const DEFAULT_IMAGE_DIMENSION: ContentSize = {width: DEFAULT_IMAGE_SIZE, height: DEFAULT_IMAGE_SIZE}; + +type ImageOnLoadEvent = NativeSyntheticEvent; + +const cachedImageDimensions = new Map(); + +type LightboxProps = { + /** Whether source url requires authentication */ + isAuthTokenRequired?: boolean; + + /** URI to full-sized attachment */ + uri: string; + + /** Triggers whenever the zoom scale changes */ + onScaleChanged?: OnScaleChangedCallback; + + /** Handles errors while displaying the image */ + onError?: () => void; + + /** Additional styles to add to the component */ + style?: StyleProp; + + /** The index of the carousel item */ + index?: number; + + /** The index of the currently active carousel item */ + activeIndex?: number; + + /** Whether the Lightbox is used within a carousel component and there are other sibling elements */ + hasSiblingCarouselItems?: boolean; + + /** Range of zoom that can be applied to the content by pinching or double tapping. */ + zoomRange?: Partial; +}; + +/** + * On the native layer, we use a image library to handle zoom functionality + */ +function Lightbox({ + isAuthTokenRequired = false, + uri, + onScaleChanged, + onError, + style, + index = 0, + activeIndex = 0, + hasSiblingCarouselItems = false, + zoomRange = DEFAULT_ZOOM_RANGE, +}: LightboxProps) { + const StyleUtils = useStyleUtils(); + + const [canvasSize, setCanvasSize] = useState(); + const isCanvasLoading = canvasSize === undefined; + const updateCanvasSize = useCallback( + ({ + nativeEvent: { + layout: {width, height}, + }, + }: LayoutChangeEvent) => setCanvasSize({width: PixelRatio.roundToNearestPixel(width), height: PixelRatio.roundToNearestPixel(height)}), + [], + ); + + const [contentSize, setInternalContentSize] = useState(() => cachedImageDimensions.get(uri)); + const setContentSize = useCallback( + (newDimensions: ContentSize | undefined) => { + setInternalContentSize(newDimensions); + cachedImageDimensions.set(uri, newDimensions); + }, + [uri], + ); + const updateContentSize = useCallback( + ({nativeEvent: {width, height}}: ImageOnLoadEvent) => { + if (contentSize !== undefined) { + return; + } + + setContentSize({width: width * PixelRatio.get(), height: height * PixelRatio.get()}); + }, + [contentSize, setContentSize], + ); + + // Enables/disables the lightbox based on the number of concurrent lightboxes + // On higher-end devices, we can show render lightboxes at the same time, + // while on lower-end devices we want to only render the active carousel item as a lightbox + // to avoid performance issues. + const isLightboxVisible = useMemo(() => { + if (!hasSiblingCarouselItems || NUMBER_OF_CONCURRENT_LIGHTBOXES === 'UNLIMITED') { + return true; + } + + const indexCanvasOffset = Math.floor((NUMBER_OF_CONCURRENT_LIGHTBOXES - 1) / 2) || 0; + const indexOutOfRange = index > activeIndex + indexCanvasOffset || index < activeIndex - indexCanvasOffset; + return !indexOutOfRange; + }, [activeIndex, hasSiblingCarouselItems, index]); + const [isLightboxImageLoaded, setLightboxImageLoaded] = useState(false); + + const [isFallbackVisible, setFallbackVisible] = useState(!isLightboxVisible); + const [isFallbackImageLoaded, setFallbackImageLoaded] = useState(false); + const fallbackSize = useMemo(() => { + if (!hasSiblingCarouselItems || !contentSize || isCanvasLoading) { + return DEFAULT_IMAGE_DIMENSION; + } + + const {minScale} = getCanvasFitScale({canvasSize, contentSize}); + + return { + width: PixelRatio.roundToNearestPixel(contentSize.width * minScale), + height: PixelRatio.roundToNearestPixel(contentSize.height * minScale), + }; + }, [hasSiblingCarouselItems, contentSize, isCanvasLoading, canvasSize]); + + // If the fallback image is currently visible, we want to hide the Lightbox by setting the opacity to 0, + // until the fallback gets hidden so that we don't see two overlapping images at the same time. + // If there the Lightbox is not used within a carousel, we don't need to hide the Lightbox, + // because it's only going to be rendered after the fallback image is hidden. + const shouldShowLightbox = isLightboxImageLoaded && !isFallbackVisible; + + const isActive = index === activeIndex; + const isFallbackStillLoading = isFallbackVisible && !isFallbackImageLoaded; + const isLightboxStillLoading = isLightboxVisible && !isLightboxImageLoaded; + const isLoading = isActive && (isCanvasLoading || isFallbackStillLoading || isLightboxStillLoading); + + // Resets the lightbox when it becomes inactive + useEffect(() => { + if (isLightboxVisible) { + return; + } + setLightboxImageLoaded(false); + setContentSize(undefined); + }, [isLightboxVisible, setContentSize]); + + // Enables and disables the fallback image when the carousel item is active or not + useEffect(() => { + // When there are no other carousel items, we don't need to show the fallback image + if (!hasSiblingCarouselItems) { + return; + } + + // When the carousel item is active and the lightbox has finished loading, we want to hide the fallback image + if (isActive && isFallbackVisible && isLightboxVisible && isLightboxImageLoaded) { + setFallbackVisible(false); + setFallbackImageLoaded(false); + return; + } + + // If the carousel item has become inactive and the lightbox is not continued to be rendered, we want to show the fallback image + if (!isActive && !isLightboxVisible) { + setFallbackVisible(true); + } + }, [hasSiblingCarouselItems, isActive, isFallbackVisible, isLightboxImageLoaded, isLightboxVisible]); + + return ( + + {!isCanvasLoading && ( + <> + {isLightboxVisible && ( + + + { + setLightboxImageLoaded(true); + }} + /> + + + )} + + {/* Keep rendering the image without gestures as fallback if the carousel item is not active and while the lightbox is loading the image */} + {isFallbackVisible && ( + + setFallbackImageLoaded(true)} + /> + + )} + + {/* Show activity indicator while the lightbox is still loading the image. */} + {isLoading && ( + + )} + + )} + + ); +} + +Lightbox.displayName = 'Lightbox'; + +export default Lightbox; diff --git a/src/components/Lightbox/numberOfConcurrentLightboxes/index.ios.ts b/src/components/Lightbox/numberOfConcurrentLightboxes/index.ios.ts new file mode 100644 index 000000000000..1ce0d2cee405 --- /dev/null +++ b/src/components/Lightbox/numberOfConcurrentLightboxes/index.ios.ts @@ -0,0 +1,8 @@ +import type LightboxConcurrencyLimit from './types'; + +// On iOS we can allow multiple lightboxes to be rendered at the same time. +// This enables faster time to interaction when swiping between pages in the carousel. +// When the lightbox is pre-rendered, we don't need to wait for the gestures to initialize. +const NUMBER_OF_CONCURRENT_LIGHTBOXES: LightboxConcurrencyLimit = 3; + +export default NUMBER_OF_CONCURRENT_LIGHTBOXES; diff --git a/src/components/Lightbox/numberOfConcurrentLightboxes/index.ts b/src/components/Lightbox/numberOfConcurrentLightboxes/index.ts new file mode 100644 index 000000000000..f6f55a8913c7 --- /dev/null +++ b/src/components/Lightbox/numberOfConcurrentLightboxes/index.ts @@ -0,0 +1,8 @@ +import type LightboxConcurrencyLimit from './types'; + +// On web, this is not used. +// On Android, we don't want to allow rendering multiple lightboxes, +// because performance is typically slower than on iOS and this caused issues. +const NUMBER_OF_CONCURRENT_LIGHTBOXES: LightboxConcurrencyLimit = 1; + +export default NUMBER_OF_CONCURRENT_LIGHTBOXES; diff --git a/src/components/Lightbox/numberOfConcurrentLightboxes/types.ts b/src/components/Lightbox/numberOfConcurrentLightboxes/types.ts new file mode 100644 index 000000000000..57aaa53cca8c --- /dev/null +++ b/src/components/Lightbox/numberOfConcurrentLightboxes/types.ts @@ -0,0 +1,5 @@ +// Increase/decrease this number to change the number of concurrent lightboxes +// The more concurrent lighboxes, the worse performance gets (especially on low-end devices) +type LightboxConcurrencyLimit = number | 'UNLIMITED'; + +export default LightboxConcurrencyLimit; diff --git a/src/components/MultiGestureCanvas/constants.ts b/src/components/MultiGestureCanvas/constants.ts new file mode 100644 index 000000000000..58ad6997bbeb --- /dev/null +++ b/src/components/MultiGestureCanvas/constants.ts @@ -0,0 +1,28 @@ +import type {WithSpringConfig} from 'react-native-reanimated'; +import type {ZoomRange} from './types'; + +const DOUBLE_TAP_SCALE = 3; + +// The spring config is used to determine the physics of the spring animation +// Details and a playground for testing different configs can be found at +// https://docs.swmansion.com/react-native-reanimated/docs/animations/withSpring +const SPRING_CONFIG: WithSpringConfig = { + mass: 1, + stiffness: 1000, + damping: 500, +}; + +// The default zoom range within the user can pinch to zoom the content inside the canvas +const DEFAULT_ZOOM_RANGE: Required = { + min: 1, + max: 20, +}; + +// The zoom range bounce factors are used to determine the amount of bounce +// that is allowed when the user zooms more than the min or max zoom levels +const ZOOM_RANGE_BOUNCE_FACTORS: Required = { + min: 0.7, + max: 1.5, +}; + +export {DOUBLE_TAP_SCALE, SPRING_CONFIG, DEFAULT_ZOOM_RANGE, ZOOM_RANGE_BOUNCE_FACTORS}; diff --git a/src/components/MultiGestureCanvas/getCanvasFitScale.ts b/src/components/MultiGestureCanvas/getCanvasFitScale.ts deleted file mode 100644 index e3e402fb066b..000000000000 --- a/src/components/MultiGestureCanvas/getCanvasFitScale.ts +++ /dev/null @@ -1,22 +0,0 @@ -type GetCanvasFitScale = (props: { - canvasSize: { - width: number; - height: number; - }; - contentSize: { - width: number; - height: number; - }; -}) => {scaleX: number; scaleY: number; minScale: number; maxScale: number}; - -const getCanvasFitScale: GetCanvasFitScale = ({canvasSize, contentSize}) => { - const scaleX = canvasSize.width / contentSize.width; - const scaleY = canvasSize.height / contentSize.height; - - const minScale = Math.min(scaleX, scaleY); - const maxScale = Math.max(scaleX, scaleY); - - return {scaleX, scaleY, minScale, maxScale}; -}; - -export default getCanvasFitScale; diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js deleted file mode 100644 index bbfb7768c461..000000000000 --- a/src/components/MultiGestureCanvas/index.js +++ /dev/null @@ -1,617 +0,0 @@ -import React, {useContext, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import Animated, { - cancelAnimation, - runOnJS, - runOnUI, - useAnimatedReaction, - useAnimatedStyle, - useDerivedValue, - useSharedValue, - useWorkletCallback, - withDecay, - withSpring, -} from 'react-native-reanimated'; -import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import getCanvasFitScale from './getCanvasFitScale'; -import {defaultZoomRange, multiGestureCanvasDefaultProps, multiGestureCanvasPropTypes} from './propTypes'; - -const DOUBLE_TAP_SCALE = 3; - -const zoomScaleBounceFactors = { - min: 0.7, - max: 1.5, -}; - -const SPRING_CONFIG = { - mass: 1, - stiffness: 1000, - damping: 500, -}; - -function clamp(value, lowerBound, upperBound) { - 'worklet'; - - return Math.min(Math.max(lowerBound, value), upperBound); -} - -function getDeepDefaultProps({contentSize: contentSizeProp = {}, zoomRange: zoomRangeProp = {}}) { - const contentSize = { - width: contentSizeProp.width == null ? 1 : contentSizeProp.width, - height: contentSizeProp.height == null ? 1 : contentSizeProp.height, - }; - - const zoomRange = { - min: zoomRangeProp.min == null ? defaultZoomRange.min : zoomRangeProp.min, - max: zoomRangeProp.max == null ? defaultZoomRange.max : zoomRangeProp.max, - }; - - return {contentSize, zoomRange}; -} - -function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, children, ...props}) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {contentSize, zoomRange} = getDeepDefaultProps(props); - - const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); - - const pagerRefFallback = useRef(null); - const {onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isScrolling, onPinchGestureChange} = attachmentCarouselPagerContext || { - onTap: () => undefined, - onSwipe: () => undefined, - onSwipeSuccess: () => undefined, - onPinchGestureChange: () => undefined, - pagerRef: pagerRefFallback, - shouldPagerScroll: false, - isScrolling: false, - ...props, - }; - - const {minScale: minContentScale, maxScale: maxContentScale} = useMemo(() => getCanvasFitScale({canvasSize, contentSize}), [canvasSize, contentSize]); - const scaledWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]); - const scaledHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]); - - // On double tap zoom to fill, but at least 3x zoom - const doubleTapScale = useMemo(() => Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale), [maxContentScale, minContentScale]); - - const zoomScale = useSharedValue(1); - // Adding together the pinch zoom scale and the initial scale to fit the content into the canvas - // Using the smaller content scale, so that the immage is not bigger than the canvas - // and not smaller than needed to fit - const totalScale = useDerivedValue(() => zoomScale.value * minContentScale, [minContentScale]); - - const zoomScaledContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]); - const zoomScaledContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]); - - // used for pan gesture - const translateY = useSharedValue(0); - const translateX = useSharedValue(0); - const offsetX = useSharedValue(0); - const offsetY = useSharedValue(0); - const isSwiping = useSharedValue(false); - - // used for moving fingers when pinching - const pinchTranslateX = useSharedValue(0); - const pinchTranslateY = useSharedValue(0); - const pinchBounceTranslateX = useSharedValue(0); - const pinchBounceTranslateY = useSharedValue(0); - - // storage for the the origin of the gesture - const origin = { - x: useSharedValue(0), - y: useSharedValue(0), - }; - - // storage for the pan velocity to calculate the decay - const panVelocityX = useSharedValue(0); - const panVelocityY = useSharedValue(0); - - // store scale in between gestures - const pinchScaleOffset = useSharedValue(1); - - // disable pan vertically when content is smaller than screen - const canPanVertically = useDerivedValue(() => canvasSize.height < zoomScaledContentHeight.value, [canvasSize.height]); - - // calculates bounds of the scaled content - // can we pan left/right/up/down - // can be used to limit gesture or implementing tension effect - const getBounds = useWorkletCallback(() => { - let rightBoundary = 0; - let topBoundary = 0; - - if (canvasSize.width < zoomScaledContentWidth.value) { - rightBoundary = Math.abs(canvasSize.width - zoomScaledContentWidth.value) / 2; - } - - if (canvasSize.height < zoomScaledContentHeight.value) { - topBoundary = Math.abs(zoomScaledContentHeight.value - canvasSize.height) / 2; - } - - const maxVector = {x: rightBoundary, y: topBoundary}; - const minVector = {x: -rightBoundary, y: -topBoundary}; - - const target = { - x: clamp(offsetX.value, minVector.x, maxVector.x), - y: clamp(offsetY.value, minVector.y, maxVector.y), - }; - - const isInBoundaryX = target.x === offsetX.value; - const isInBoundaryY = target.y === offsetY.value; - - return { - target, - isInBoundaryX, - isInBoundaryY, - minVector, - maxVector, - canPanLeft: target.x < maxVector.x, - canPanRight: target.x > minVector.x, - }; - }, [canvasSize.width, canvasSize.height]); - - const afterPanGesture = useWorkletCallback(() => { - const {target, isInBoundaryX, isInBoundaryY, minVector, maxVector} = getBounds(); - - if (!canPanVertically.value) { - offsetY.value = withSpring(target.y, SPRING_CONFIG); - } - - if (zoomScale.value === 1 && offsetX.value === 0 && offsetY.value === 0 && translateX.value === 0 && translateY.value === 0) { - // we don't need to run any animations - return; - } - - if (zoomScale.value <= 1) { - // just center it - offsetX.value = withSpring(0, SPRING_CONFIG); - offsetY.value = withSpring(0, SPRING_CONFIG); - return; - } - - const deceleration = 0.9915; - - if (isInBoundaryX) { - if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= zoomRange.max) { - offsetX.value = withDecay({ - velocity: panVelocityX.value, - clamp: [minVector.x, maxVector.x], - deceleration, - rubberBandEffect: false, - }); - } - } else { - offsetX.value = withSpring(target.x, SPRING_CONFIG); - } - - if (isInBoundaryY) { - if ( - Math.abs(panVelocityY.value) > 0 && - zoomScale.value <= zoomRange.max && - // limit vertical pan only when content is smaller than screen - offsetY.value !== minVector.y && - offsetY.value !== maxVector.y - ) { - offsetY.value = withDecay({ - velocity: panVelocityY.value, - clamp: [minVector.y, maxVector.y], - deceleration, - }); - } - } else { - offsetY.value = withSpring(target.y, SPRING_CONFIG, () => { - isSwiping.value = false; - }); - } - }); - - const stopAnimation = useWorkletCallback(() => { - cancelAnimation(offsetX); - cancelAnimation(offsetY); - }); - - const zoomToCoordinates = useWorkletCallback( - (canvasFocalX, canvasFocalY) => { - 'worklet'; - - stopAnimation(); - - const canvasOffsetX = Math.max(0, (canvasSize.width - scaledWidth) / 2); - const canvasOffsetY = Math.max(0, (canvasSize.height - scaledHeight) / 2); - - const contentFocal = { - x: clamp(canvasFocalX - canvasOffsetX, 0, scaledWidth), - y: clamp(canvasFocalY - canvasOffsetY, 0, scaledHeight), - }; - - const canvasCenter = { - x: canvasSize.width / 2, - y: canvasSize.height / 2, - }; - - const originContentCenter = { - x: scaledWidth / 2, - y: scaledHeight / 2, - }; - - const targetContentSize = { - width: scaledWidth * doubleTapScale, - height: scaledHeight * doubleTapScale, - }; - - const targetContentCenter = { - x: targetContentSize.width / 2, - y: targetContentSize.height / 2, - }; - - const currentOrigin = { - x: (targetContentCenter.x - canvasCenter.x) * -1, - y: (targetContentCenter.y - canvasCenter.y) * -1, - }; - - const koef = { - x: (1 / originContentCenter.x) * contentFocal.x - 1, - y: (1 / originContentCenter.y) * contentFocal.y - 1, - }; - - const target = { - x: currentOrigin.x * koef.x, - y: currentOrigin.y * koef.y, - }; - - if (targetContentSize.height < canvasSize.height) { - target.y = 0; - } - - offsetX.value = withSpring(target.x, SPRING_CONFIG); - offsetY.value = withSpring(target.y, SPRING_CONFIG); - zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG); - pinchScaleOffset.value = doubleTapScale; - }, - [scaledWidth, scaledHeight, canvasSize, doubleTapScale], - ); - - const reset = useWorkletCallback((animated) => { - pinchScaleOffset.value = 1; - - stopAnimation(); - - if (animated) { - offsetX.value = withSpring(0, SPRING_CONFIG); - offsetY.value = withSpring(0, SPRING_CONFIG); - zoomScale.value = withSpring(1, SPRING_CONFIG); - } else { - zoomScale.value = 1; - translateX.value = 0; - translateY.value = 0; - offsetX.value = 0; - offsetY.value = 0; - pinchTranslateX.value = 0; - pinchTranslateY.value = 0; - } - }); - - const doubleTap = Gesture.Tap() - .numberOfTaps(2) - .maxDelay(150) - .maxDistance(20) - .onEnd((evt) => { - if (zoomScale.value > 1) { - reset(true); - } else { - zoomToCoordinates(evt.x, evt.y); - } - - if (onScaleChanged != null) { - runOnJS(onScaleChanged)(zoomScale.value); - } - }); - - const panGestureRef = useRef(Gesture.Pan()); - - const singleTap = Gesture.Tap() - .numberOfTaps(1) - .maxDuration(50) - .requireExternalGestureToFail(doubleTap, panGestureRef) - .onBegin(() => { - stopAnimation(); - }) - .onFinalize((evt, success) => { - if (!success || !onTap) { - return; - } - - runOnJS(onTap)(); - }); - - const previousTouch = useSharedValue(null); - - const panGesture = Gesture.Pan() - .manualActivation(true) - .averageTouches(true) - .onTouchesMove((evt, state) => { - if (zoomScale.value > 1) { - state.activate(); - } - - // TODO: Swipe down to close carousel gesture - // this needs fine tuning to work properly - // if (!isScrolling.value && scale.value === 1 && previousTouch.value != null) { - // const velocityX = Math.abs(evt.allTouches[0].x - previousTouch.value.x); - // const velocityY = evt.allTouches[0].y - previousTouch.value.y; - - // // TODO: this needs tuning - // if (Math.abs(velocityY) > velocityX && velocityY > 20) { - // state.activate(); - - // isSwiping.value = true; - // previousTouch.value = null; - - // runOnJS(onSwipeDown)(); - // return; - // } - // } - - if (previousTouch.value == null) { - previousTouch.value = { - x: evt.allTouches[0].x, - y: evt.allTouches[0].y, - }; - } - }) - .simultaneousWithExternalGesture(pagerRef, doubleTap, singleTap) - .onBegin(() => { - stopAnimation(); - }) - .onChange((evt) => { - // since we running both pinch and pan gesture handlers simultaneously - // we need to make sure that we don't pan when we pinch and move fingers - // since we track it as pinch focal gesture - if (evt.numberOfPointers > 1 || isScrolling.value) { - return; - } - - panVelocityX.value = evt.velocityX; - - panVelocityY.value = evt.velocityY; - - if (!isSwiping.value) { - translateX.value += evt.changeX; - } - - if (canPanVertically.value || isSwiping.value) { - translateY.value += evt.changeY; - } - }) - .onEnd((evt) => { - previousTouch.value = null; - - if (isScrolling.value) { - return; - } - - offsetX.value += translateX.value; - offsetY.value += translateY.value; - translateX.value = 0; - translateY.value = 0; - - if (isSwiping.value) { - const enoughVelocity = Math.abs(evt.velocityY) > 300 && Math.abs(evt.velocityX) < Math.abs(evt.velocityY); - const rightDirection = (evt.translationY > 0 && evt.velocityY > 0) || (evt.translationY < 0 && evt.velocityY < 0); - - if (enoughVelocity && rightDirection) { - const maybeInvert = (v) => { - const invert = evt.velocityY < 0; - return invert ? -v : v; - }; - - offsetY.value = withSpring( - maybeInvert(contentSize.height * 2), - { - stiffness: 50, - damping: 30, - mass: 1, - overshootClamping: true, - restDisplacementThreshold: 300, - restSpeedThreshold: 300, - velocity: Math.abs(evt.velocityY) < 1200 ? maybeInvert(1200) : evt.velocityY, - }, - () => { - runOnJS(onSwipeSuccess)(); - }, - ); - return; - } - } - - afterPanGesture(); - - panVelocityX.value = 0; - panVelocityY.value = 0; - }) - .withRef(panGestureRef); - - const getAdjustedFocal = useWorkletCallback( - (focalX, focalY) => ({ - x: focalX - (canvasSize.width / 2 + offsetX.value), - y: focalY - (canvasSize.height / 2 + offsetY.value), - }), - [canvasSize.width, canvasSize.height], - ); - - // used to store event scale value when we limit scale - const pinchGestureScale = useSharedValue(1); - const pinchGestureRunning = useSharedValue(false); - - const [pinchEnabled, setPinchEnabled] = useState(true); - useEffect(() => { - if (pinchEnabled) { - return; - } - setPinchEnabled(true); - }, [pinchEnabled]); - - const pinchGesture = Gesture.Pinch() - .enabled(pinchEnabled) - .onTouchesDown((evt, state) => { - // we don't want to activate pinch gesture when we are scrolling pager - if (!isScrolling.value) { - return; - } - - state.fail(); - }) - .simultaneousWithExternalGesture(panGesture, doubleTap) - .onStart((evt) => { - pinchGestureRunning.value = true; - - stopAnimation(); - - const adjustFocal = getAdjustedFocal(evt.focalX, evt.focalY); - - origin.x.value = adjustFocal.x; - origin.y.value = adjustFocal.y; - }) - .onChange((evt) => { - if (evt.numberOfPointers !== 2) { - runOnJS(setPinchEnabled)(false); - return; - } - - const newZoomScale = pinchScaleOffset.value * evt.scale; - - if (zoomScale.value >= zoomRange.min * zoomScaleBounceFactors.min && zoomScale.value <= zoomRange.max * zoomScaleBounceFactors.max) { - zoomScale.value = newZoomScale; - pinchGestureScale.value = evt.scale; - } - - const adjustedFocal = getAdjustedFocal(evt.focalX, evt.focalY); - const newPinchTranslateX = adjustedFocal.x + pinchGestureScale.value * origin.x.value * -1; - const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * origin.y.value * -1; - - if (zoomScale.value >= zoomRange.min && zoomScale.value <= zoomRange.max) { - pinchTranslateX.value = newPinchTranslateX; - pinchTranslateY.value = newPinchTranslateY; - } else { - pinchBounceTranslateX.value = newPinchTranslateX - pinchTranslateX.value; - pinchBounceTranslateY.value = newPinchTranslateY - pinchTranslateY.value; - } - }) - .onEnd(() => { - offsetX.value += pinchTranslateX.value; - offsetY.value += pinchTranslateY.value; - pinchTranslateX.value = 0; - pinchTranslateY.value = 0; - pinchScaleOffset.value = zoomScale.value; - pinchGestureScale.value = 1; - - if (pinchScaleOffset.value < zoomRange.min) { - pinchScaleOffset.value = zoomRange.min; - zoomScale.value = withSpring(zoomRange.min, SPRING_CONFIG); - } else if (pinchScaleOffset.value > zoomRange.max) { - pinchScaleOffset.value = zoomRange.max; - zoomScale.value = withSpring(zoomRange.max, SPRING_CONFIG); - } - - if (pinchBounceTranslateX.value !== 0 || pinchBounceTranslateY.value !== 0) { - pinchBounceTranslateX.value = withSpring(0, SPRING_CONFIG); - pinchBounceTranslateY.value = withSpring(0, SPRING_CONFIG); - } - - pinchGestureRunning.value = false; - - if (onScaleChanged != null) { - runOnJS(onScaleChanged)(zoomScale.value); - } - }); - - const [isPinchGestureInUse, setIsPinchGestureInUse] = useState(false); - useAnimatedReaction( - () => [zoomScale.value, pinchGestureRunning.value], - ([zoom, running]) => { - const newIsPinchGestureInUse = zoom !== 1 || running; - if (isPinchGestureInUse !== newIsPinchGestureInUse) { - runOnJS(setIsPinchGestureInUse)(newIsPinchGestureInUse); - } - }, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => onPinchGestureChange(isPinchGestureInUse), [isPinchGestureInUse]); - - const animatedStyles = useAnimatedStyle(() => { - const x = pinchTranslateX.value + pinchBounceTranslateX.value + translateX.value + offsetX.value; - const y = pinchTranslateY.value + pinchBounceTranslateY.value + translateY.value + offsetY.value; - - if (isSwiping.value) { - onSwipe(y); - } - - return { - transform: [ - { - translateX: x, - }, - { - translateY: y, - }, - {scale: totalScale.value}, - ], - }; - }); - - // reacts to scale change and enables/disables pager scroll - useAnimatedReaction( - () => zoomScale.value, - () => { - shouldPagerScroll.value = zoomScale.value === 1; - }, - ); - - const mounted = useRef(false); - useEffect(() => { - if (!mounted.current) { - mounted.current = true; - return; - } - - if (!isActive) { - runOnUI(reset)(false); - } - }, [isActive, mounted, reset]); - - return ( - - - - - {children} - - - - - ); -} -MultiGestureCanvas.propTypes = multiGestureCanvasPropTypes; -MultiGestureCanvas.defaultProps = multiGestureCanvasDefaultProps; -MultiGestureCanvas.displayName = 'MultiGestureCanvas'; - -export default MultiGestureCanvas; -export {defaultZoomRange, zoomScaleBounceFactors}; diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx new file mode 100644 index 000000000000..a7eca6baa18f --- /dev/null +++ b/src/components/MultiGestureCanvas/index.tsx @@ -0,0 +1,266 @@ +import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import {View} from 'react-native'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import type {GestureRef} from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gesture'; +import Animated, {cancelAnimation, runOnUI, useAnimatedStyle, useDerivedValue, useSharedValue, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import {DEFAULT_ZOOM_RANGE, SPRING_CONFIG, ZOOM_RANGE_BOUNCE_FACTORS} from './constants'; +import type {CanvasSize, ContentSize, OnScaleChangedCallback, ZoomRange} from './types'; +import usePanGesture from './usePanGesture'; +import usePinchGesture from './usePinchGesture'; +import useTapGestures from './useTapGestures'; +import * as MultiGestureCanvasUtils from './utils'; + +type MultiGestureCanvasProps = ChildrenProps & { + /** + * Wheter the canvas is currently active (in the screen) or not. + * Disables certain gestures and functionality + */ + isActive?: boolean; + + /** The width and height of the canvas. + * This is needed in order to properly scale the content in the canvas + */ + canvasSize: CanvasSize; + + /** The width and height of the content. + * This is needed in order to properly scale the content in the canvas + */ + contentSize?: ContentSize; + + /** Range of zoom that can be applied to the content by pinching or double tapping. */ + zoomRange?: Partial; + + /** Handles scale changed event */ + onScaleChanged?: OnScaleChangedCallback; +}; + +function MultiGestureCanvas({ + canvasSize, + contentSize = {width: 1, height: 1}, + zoomRange: zoomRangeProp, + isActive = true, + children, + onScaleChanged: onScaleChangedProp, +}: MultiGestureCanvasProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const isSwipingInPagerFallback = useSharedValue(false); + + // If the MultiGestureCanvas used inside a AttachmentCarouselPager, we need to adapt the behaviour based on the pager state + const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); + const { + onTap, + onScaleChanged: onScaleChangedContext, + isPagerScrolling: isPagerSwiping, + pagerRef, + } = useMemo( + () => + attachmentCarouselPagerContext ?? { + onTap: () => {}, + onScaleChanged: () => {}, + pagerRef: undefined, + isPagerScrolling: isSwipingInPagerFallback, + }, + [attachmentCarouselPagerContext, isSwipingInPagerFallback], + ); + + /** + * Calls the onScaleChanged callback from the both props and the pager context + */ + const onScaleChanged = useCallback( + (newScale: number) => { + onScaleChangedProp?.(newScale); + onScaleChangedContext(newScale); + }, + [onScaleChangedContext, onScaleChangedProp], + ); + + const zoomRange = useMemo( + () => ({ + min: zoomRangeProp?.min ?? DEFAULT_ZOOM_RANGE.min, + max: zoomRangeProp?.max ?? DEFAULT_ZOOM_RANGE.max, + }), + [zoomRangeProp?.max, zoomRangeProp?.min], + ); + + // Based on the (original) content size and the canvas size, we calculate the horizontal and vertical scale factors + // to fit the content inside the canvas + // We later use the lower of the two scale factors to fit the content inside the canvas + const {minScale: minContentScale, maxScale: maxContentScale} = useMemo(() => MultiGestureCanvasUtils.getCanvasFitScale({canvasSize, contentSize}), [canvasSize, contentSize]); + + const zoomScale = useSharedValue(1); + + // Adding together zoom scale and the initial scale to fit the content into the canvas + // Using the minimum content scale, so that the image is not bigger than the canvas + // and not smaller than needed to fit + const totalScale = useDerivedValue(() => zoomScale.value * minContentScale, [minContentScale]); + + const panTranslateX = useSharedValue(0); + const panTranslateY = useSharedValue(0); + const panGestureRef = useRef(Gesture.Pan()); + + const pinchScale = useSharedValue(1); + const pinchTranslateX = useSharedValue(0); + const pinchTranslateY = useSharedValue(0); + + // Total offset of the content including previous translations from panning and pinching gestures + const offsetX = useSharedValue(0); + const offsetY = useSharedValue(0); + + /** + * Stops any currently running decay animation from panning + */ + const stopAnimation = useWorkletCallback(() => { + cancelAnimation(offsetX); + cancelAnimation(offsetY); + }); + + /** + * Resets the canvas to the initial state and animates back smoothly + */ + const reset = useWorkletCallback((animated: boolean, callback?: () => void) => { + stopAnimation(); + + pinchScale.value = 1; + + if (animated) { + offsetX.value = withSpring(0, SPRING_CONFIG); + offsetY.value = withSpring(0, SPRING_CONFIG); + panTranslateX.value = withSpring(0, SPRING_CONFIG); + panTranslateY.value = withSpring(0, SPRING_CONFIG); + pinchTranslateX.value = withSpring(0, SPRING_CONFIG); + pinchTranslateY.value = withSpring(0, SPRING_CONFIG); + zoomScale.value = withSpring(1, SPRING_CONFIG, callback); + + return; + } + + offsetX.value = 0; + offsetY.value = 0; + panTranslateX.value = 0; + panTranslateY.value = 0; + pinchTranslateX.value = 0; + pinchTranslateY.value = 0; + zoomScale.value = 1; + + if (callback === undefined) { + return; + } + + callback(); + }); + + const {singleTapGesture: basicSingleTapGesture, doubleTapGesture} = useTapGestures({ + canvasSize, + contentSize, + minContentScale, + maxContentScale, + offsetX, + offsetY, + pinchScale, + zoomScale, + reset, + stopAnimation, + onScaleChanged, + onTap, + }); + const singleTapGesture = basicSingleTapGesture.requireExternalGestureToFail(doubleTapGesture, panGestureRef); + + const panGestureSimultaneousList = useMemo( + () => (pagerRef === undefined ? [singleTapGesture, doubleTapGesture] : [pagerRef as unknown as Exclude, singleTapGesture, doubleTapGesture]), + [doubleTapGesture, pagerRef, singleTapGesture], + ); + + const panGesture = usePanGesture({ + canvasSize, + contentSize, + zoomScale, + totalScale, + offsetX, + offsetY, + panTranslateX, + panTranslateY, + isPagerSwiping, + stopAnimation, + }) + .simultaneousWithExternalGesture(...panGestureSimultaneousList) + .withRef(panGestureRef); + + const pinchGesture = usePinchGesture({ + canvasSize, + zoomScale, + zoomRange, + offsetX, + offsetY, + pinchTranslateX, + pinchTranslateY, + pinchScale, + isPagerSwiping, + stopAnimation, + onScaleChanged, + }).simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture); + + // Trigger a reset when the canvas gets inactive, but only if it was already mounted before + const mounted = useRef(false); + useEffect(() => { + if (!mounted.current) { + mounted.current = true; + return; + } + + if (!isActive) { + runOnUI(reset)(false); + } + }, [isActive, mounted, reset]); + + // Animate the x and y position of the content within the canvas based on all of the gestures + const animatedStyles = useAnimatedStyle(() => { + const x = pinchTranslateX.value + panTranslateX.value + offsetX.value; + const y = pinchTranslateY.value + panTranslateY.value + offsetY.value; + + return { + transform: [ + { + translateX: x, + }, + { + translateY: y, + }, + {scale: totalScale.value}, + ], + }; + }); + + const containerStyles = useMemo(() => [styles.flex1, StyleUtils.getMultiGestureCanvasContainerStyle(canvasSize.width)], [StyleUtils, canvasSize.width, styles.flex1]); + + return ( + + + + + {children} + + + + + ); +} +MultiGestureCanvas.displayName = 'MultiGestureCanvas'; + +export default MultiGestureCanvas; +export {DEFAULT_ZOOM_RANGE, ZOOM_RANGE_BOUNCE_FACTORS}; +export type {MultiGestureCanvasProps}; diff --git a/src/components/MultiGestureCanvas/propTypes.js b/src/components/MultiGestureCanvas/propTypes.js deleted file mode 100644 index f1961ec0e156..000000000000 --- a/src/components/MultiGestureCanvas/propTypes.js +++ /dev/null @@ -1,73 +0,0 @@ -import PropTypes from 'prop-types'; - -const defaultZoomRange = { - min: 1, - max: 20, -}; - -const zoomRangePropTypes = { - /** Range of zoom that can be applied to the content by pinching or double tapping. */ - zoomRange: PropTypes.shape({ - min: PropTypes.number, - max: PropTypes.number, - }), -}; - -const zoomRangeDefaultProps = { - zoomRange: { - min: defaultZoomRange.min, - max: defaultZoomRange.max, - }, -}; - -const multiGestureCanvasPropTypes = { - ...zoomRangePropTypes, - - /** - * Wheter the canvas is currently active (in the screen) or not. - * Disables certain gestures and functionality - */ - isActive: PropTypes.bool, - - /** Handles scale changed event */ - onScaleChanged: PropTypes.func, - - /** The width and height of the canvas. - * This is needed in order to properly scale the content in the canvas - */ - canvasSize: PropTypes.shape({ - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - }).isRequired, - - /** The width and height of the content. - * This is needed in order to properly scale the content in the canvas - */ - contentSize: PropTypes.shape({ - width: PropTypes.number, - height: PropTypes.number, - }), - - /** The scale factors (scaleX, scaleY) that are used to scale the content (width/height) to the canvas size. - * `scaledWidth` and `scaledHeight` reflect the actual size of the content after scaling. - */ - contentScaling: PropTypes.shape({ - scaleX: PropTypes.number, - scaleY: PropTypes.number, - scaledWidth: PropTypes.number, - scaledHeight: PropTypes.number, - }), - - /** Content that should be transformed inside the canvas (images, pdf, ...) */ - children: PropTypes.node.isRequired, -}; - -const multiGestureCanvasDefaultProps = { - isActive: true, - onScaleChanged: () => undefined, - contentSize: undefined, - contentScaling: undefined, - zoomRange: undefined, -}; - -export {defaultZoomRange, zoomRangePropTypes, zoomRangeDefaultProps, multiGestureCanvasPropTypes, multiGestureCanvasDefaultProps}; diff --git a/src/components/MultiGestureCanvas/types.ts b/src/components/MultiGestureCanvas/types.ts index 0242f045feef..bbd8f69e6947 100644 --- a/src/components/MultiGestureCanvas/types.ts +++ b/src/components/MultiGestureCanvas/types.ts @@ -1,6 +1,50 @@ +import type {SharedValue} from 'react-native-reanimated'; + +/** Dimensions of the canvas rendered by the MultiGestureCanvas */ +type CanvasSize = { + width: number; + height: number; +}; + +/** Dimensions of the content passed to the MultiGestureCanvas */ +type ContentSize = { + width: number; + height: number; +}; + +/** Range of zoom that can be applied to the content by pinching or double tapping. */ type ZoomRange = { min: number; max: number; }; -export default ZoomRange; +/** Triggered whenever the scale of the MultiGestureCanvas changes */ +type OnScaleChangedCallback = (zoomScale: number) => void; + +/** Triggered when the canvas is tapped (single tap) */ +type OnTapCallback = () => void; + +/** Types used of variables used within the MultiGestureCanvas component and it's hooks */ +type MultiGestureCanvasVariables = { + canvasSize: CanvasSize; + contentSize: ContentSize; + zoomRange: ZoomRange; + minContentScale: number; + maxContentScale: number; + isPagerSwiping: SharedValue; + zoomScale: SharedValue; + totalScale: SharedValue; + pinchScale: SharedValue; + offsetX: SharedValue; + offsetY: SharedValue; + panTranslateX: SharedValue; + panTranslateY: SharedValue; + pinchTranslateX: SharedValue; + pinchTranslateY: SharedValue; + stopAnimation: () => void; + reset: (animated: boolean, callback: () => void) => void; + onTap: OnTapCallback; + onScaleChanged: OnScaleChangedCallback | undefined; +}; + +export type {CanvasSize, ContentSize, ZoomRange, OnScaleChangedCallback, MultiGestureCanvasVariables}; diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts new file mode 100644 index 000000000000..8a646446fad4 --- /dev/null +++ b/src/components/MultiGestureCanvas/usePanGesture.ts @@ -0,0 +1,160 @@ +/* eslint-disable no-param-reassign */ +import type {PanGesture} from 'react-native-gesture-handler'; +import {Gesture} from 'react-native-gesture-handler'; +import {useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated'; +import {SPRING_CONFIG} from './constants'; +import type {MultiGestureCanvasVariables} from './types'; +import * as MultiGestureCanvasUtils from './utils'; + +// This value determines how fast the pan animation should phase out +// We're using a "withDecay" animation to smoothly phase out the pan animation +// https://docs.swmansion.com/react-native-reanimated/docs/animations/withDecay/ +const PAN_DECAY_DECELARATION = 0.9915; + +type UsePanGestureProps = Pick< + MultiGestureCanvasVariables, + 'canvasSize' | 'contentSize' | 'zoomScale' | 'totalScale' | 'offsetX' | 'offsetY' | 'panTranslateX' | 'panTranslateY' | 'isPagerSwiping' | 'stopAnimation' +>; + +const usePanGesture = ({canvasSize, contentSize, zoomScale, totalScale, offsetX, offsetY, panTranslateX, panTranslateY, isPagerSwiping, stopAnimation}: UsePanGestureProps): PanGesture => { + // The content size after fitting it to the canvas and zooming + const zoomedContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]); + const zoomedContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]); + + // Velocity of the pan gesture + // We need to keep track of the velocity to properly phase out/decay the pan animation + const panVelocityX = useSharedValue(0); + const panVelocityY = useSharedValue(0); + + // Calculates bounds of the scaled content + // Can we pan left/right/up/down + // Can be used to limit gesture or implementing tension effect + const getBounds = useWorkletCallback(() => { + let horizontalBoundary = 0; + let verticalBoundary = 0; + + if (canvasSize.width < zoomedContentWidth.value) { + horizontalBoundary = Math.abs(canvasSize.width - zoomedContentWidth.value) / 2; + } + + if (canvasSize.height < zoomedContentHeight.value) { + verticalBoundary = Math.abs(zoomedContentHeight.value - canvasSize.height) / 2; + } + + const horizontalBoundaries = {min: -horizontalBoundary, max: horizontalBoundary}; + const verticalBoundaries = {min: -verticalBoundary, max: verticalBoundary}; + + const clampedOffset = { + x: MultiGestureCanvasUtils.clamp(offsetX.value, horizontalBoundaries.min, horizontalBoundaries.max), + y: MultiGestureCanvasUtils.clamp(offsetY.value, verticalBoundaries.min, verticalBoundaries.max), + }; + + // If the horizontal/vertical offset is the same after clamping to the min/max boundaries, the content is within the boundaries + const isInHoriztontalBoundary = clampedOffset.x === offsetX.value; + const isInVerticalBoundary = clampedOffset.y === offsetY.value; + + return { + horizontalBoundaries, + verticalBoundaries, + clampedOffset, + isInHoriztontalBoundary, + isInVerticalBoundary, + }; + }, [canvasSize.width, canvasSize.height]); + + // We want to smoothly decay/end the gesture by phasing out the pan animation + // In case the content is outside of the boundaries of the canvas, + // we need to move the content back into the boundaries + const finishPanGesture = useWorkletCallback(() => { + // If the content is centered within the canvas, we don't need to run any animations + if (offsetX.value === 0 && offsetY.value === 0 && panTranslateX.value === 0 && panTranslateY.value === 0) { + return; + } + + const {clampedOffset, isInHoriztontalBoundary, isInVerticalBoundary, horizontalBoundaries, verticalBoundaries} = getBounds(); + + // If the content is within the horizontal/vertical boundaries of the canvas, we can smoothly phase out the animation + // If not, we need to snap back to the boundaries + if (isInHoriztontalBoundary) { + // If the (absolute) velocity is 0, we don't need to run an animation + if (Math.abs(panVelocityX.value) !== 0) { + // Phase out the pan animation + offsetX.value = withDecay({ + velocity: panVelocityX.value, + clamp: [horizontalBoundaries.min, horizontalBoundaries.max], + deceleration: PAN_DECAY_DECELARATION, + rubberBandEffect: false, + }); + } + } else { + // Animated back to the boundary + offsetX.value = withSpring(clampedOffset.x, SPRING_CONFIG); + } + + if (isInVerticalBoundary) { + // If the (absolute) velocity is 0, we don't need to run an animation + if (Math.abs(panVelocityY.value) !== 0) { + // Phase out the pan animation + offsetY.value = withDecay({ + velocity: panVelocityY.value, + clamp: [verticalBoundaries.min, verticalBoundaries.max], + deceleration: PAN_DECAY_DECELARATION, + }); + } + } else { + // Animated back to the boundary + offsetY.value = withSpring(clampedOffset.y, SPRING_CONFIG); + } + + // Reset velocity variables after we finished the pan gesture + panVelocityX.value = 0; + panVelocityY.value = 0; + }); + + const panGesture = Gesture.Pan() + .manualActivation(true) + .averageTouches(true) + // eslint-disable-next-line @typescript-eslint/naming-convention + .onTouchesMove((_evt, state) => { + // We only allow panning when the content is zoomed in + if (zoomScale.value <= 1 || isPagerSwiping.value) { + return; + } + + state.activate(); + }) + .onStart(() => { + stopAnimation(); + }) + .onChange((evt) => { + // Since we're running both pinch and pan gesture handlers simultaneously, + // we need to make sure that we don't pan when we pinch since we track it as pinch focal gesture. + if (evt.numberOfPointers > 1) { + return; + } + + panVelocityX.value = evt.velocityX; + panVelocityY.value = evt.velocityY; + + panTranslateX.value += evt.changeX; + panTranslateY.value += evt.changeY; + }) + .onEnd(() => { + // Add pan translation to total offset and reset gesture variables + offsetX.value += panTranslateX.value; + offsetY.value += panTranslateY.value; + panTranslateX.value = 0; + panTranslateY.value = 0; + + // If we are swiping (in the pager), we don't want to return to boundaries + if (isPagerSwiping.value) { + return; + } + + finishPanGesture(); + }); + + return panGesture; +}; + +export default usePanGesture; diff --git a/src/components/MultiGestureCanvas/usePinchGesture.ts b/src/components/MultiGestureCanvas/usePinchGesture.ts new file mode 100644 index 000000000000..2ff375dc7edd --- /dev/null +++ b/src/components/MultiGestureCanvas/usePinchGesture.ts @@ -0,0 +1,174 @@ +/* eslint-disable no-param-reassign */ +import {useEffect, useState} from 'react'; +import type {PinchGesture} from 'react-native-gesture-handler'; +import {Gesture} from 'react-native-gesture-handler'; +import {runOnJS, useAnimatedReaction, useSharedValue, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import {SPRING_CONFIG, ZOOM_RANGE_BOUNCE_FACTORS} from './constants'; +import type {MultiGestureCanvasVariables} from './types'; + +type UsePinchGestureProps = Pick< + MultiGestureCanvasVariables, + 'canvasSize' | 'zoomScale' | 'zoomRange' | 'offsetX' | 'offsetY' | 'pinchTranslateX' | 'pinchTranslateY' | 'pinchScale' | 'isPagerSwiping' | 'stopAnimation' | 'onScaleChanged' +>; + +const usePinchGesture = ({ + canvasSize, + zoomScale, + zoomRange, + offsetX, + offsetY, + pinchTranslateX: totalPinchTranslateX, + pinchTranslateY: totalPinchTranslateY, + pinchScale, + isPagerSwiping, + stopAnimation, + onScaleChanged, +}: UsePinchGestureProps): PinchGesture => { + // The current pinch gesture event scale + const currentPinchScale = useSharedValue(1); + + // Origin of the pinch gesture + const pinchOrigin = { + x: useSharedValue(0), + y: useSharedValue(0), + }; + + // How much the content is translated during the pinch gesture + // While the pinch gesture is running, the pan gesture is disabled + // Therefore we need to add the translation separately + const pinchTranslateX = useSharedValue(0); + const pinchTranslateY = useSharedValue(0); + + // In order to keep track of the "bounce" effect when "overzooming"/"underzooming", + // we need to have extra "bounce" translation variables + const pinchBounceTranslateX = useSharedValue(0); + const pinchBounceTranslateY = useSharedValue(0); + + const triggerScaleChangedEvent = () => { + 'worklet'; + + if (onScaleChanged === undefined) { + return; + } + + runOnJS(onScaleChanged)(zoomScale.value); + }; + + // Update the total (pinch) translation based on the regular pinch + bounce + useAnimatedReaction( + () => [pinchTranslateX.value, pinchTranslateY.value, pinchBounceTranslateX.value, pinchBounceTranslateY.value], + ([translateX, translateY, bounceX, bounceY]) => { + totalPinchTranslateX.value = translateX + bounceX; + totalPinchTranslateY.value = translateY + bounceY; + }, + ); + + /** + * Calculates the adjusted focal point of the pinch gesture, + * based on the canvas size and the current offset + */ + const getAdjustedFocal = useWorkletCallback( + (focalX: number, focalY: number) => ({ + x: focalX - (canvasSize.width / 2 + offsetX.value), + y: focalY - (canvasSize.height / 2 + offsetY.value), + }), + [canvasSize.width, canvasSize.height], + ); + + // The pinch gesture is disabled when we release one of the fingers + // On the next render, we need to re-enable the pinch gesture + const [pinchEnabled, setPinchEnabled] = useState(true); + useEffect(() => { + if (pinchEnabled) { + return; + } + setPinchEnabled(true); + }, [pinchEnabled]); + + const pinchGesture = Gesture.Pinch() + .enabled(pinchEnabled) + // eslint-disable-next-line @typescript-eslint/naming-convention + .onTouchesDown((_evt, state) => { + // We don't want to activate pinch gesture when we are swiping in the pager + if (!isPagerSwiping.value) { + return; + } + + state.fail(); + }) + .onStart((evt) => { + stopAnimation(); + + // Set the origin focal point of the pinch gesture at the start of the gesture + const adjustedFocal = getAdjustedFocal(evt.focalX, evt.focalY); + pinchOrigin.x.value = adjustedFocal.x; + pinchOrigin.y.value = adjustedFocal.y; + }) + .onChange((evt) => { + // Disable the pinch gesture if one finger is released, + // to prevent the content from shaking/jumping + if (evt.numberOfPointers !== 2) { + runOnJS(setPinchEnabled)(false); + return; + } + + const newZoomScale = pinchScale.value * evt.scale; + + // Limit the zoom scale to zoom range including bounce range + if (zoomScale.value >= zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min && zoomScale.value <= zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max) { + zoomScale.value = newZoomScale; + currentPinchScale.value = evt.scale; + + triggerScaleChangedEvent(); + } + + // Calculate new pinch translation + const adjustedFocal = getAdjustedFocal(evt.focalX, evt.focalY); + const newPinchTranslateX = adjustedFocal.x + currentPinchScale.value * pinchOrigin.x.value * -1; + const newPinchTranslateY = adjustedFocal.y + currentPinchScale.value * pinchOrigin.y.value * -1; + + // If the zoom scale is within the zoom range, we perform the regular pinch translation + // Otherwise it means that we are "overzoomed" or "underzoomed", so we need to bounce back + if (zoomScale.value >= zoomRange.min && zoomScale.value <= zoomRange.max) { + pinchTranslateX.value = newPinchTranslateX; + pinchTranslateY.value = newPinchTranslateY; + } else { + // Store x and y translation that is produced while bouncing + // so we can revert the bounce once pinch gesture is released + pinchBounceTranslateX.value = newPinchTranslateX - pinchTranslateX.value; + pinchBounceTranslateY.value = newPinchTranslateY - pinchTranslateY.value; + } + }) + .onEnd(() => { + // Add pinch translation to total offset and reset gesture variables + offsetX.value += pinchTranslateX.value; + offsetY.value += pinchTranslateY.value; + pinchTranslateX.value = 0; + pinchTranslateY.value = 0; + currentPinchScale.value = 1; + + // If the content was "overzoomed" or "underzoomed", we need to bounce back with an animation + if (pinchBounceTranslateX.value !== 0 || pinchBounceTranslateY.value !== 0) { + pinchBounceTranslateX.value = withSpring(0, SPRING_CONFIG); + pinchBounceTranslateY.value = withSpring(0, SPRING_CONFIG); + } + + if (zoomScale.value < zoomRange.min) { + // If the zoom scale is less than the minimum zoom scale, we need to set the zoom scale to the minimum + pinchScale.value = zoomRange.min; + zoomScale.value = withSpring(zoomRange.min, SPRING_CONFIG, triggerScaleChangedEvent); + } else if (zoomScale.value > zoomRange.max) { + // If the zoom scale is higher than the maximum zoom scale, we need to set the zoom scale to the maximum + pinchScale.value = zoomRange.max; + zoomScale.value = withSpring(zoomRange.max, SPRING_CONFIG, triggerScaleChangedEvent); + } else { + // Otherwise, we just update the pinch scale offset + pinchScale.value = zoomScale.value; + triggerScaleChangedEvent(); + } + }); + + return pinchGesture; +}; + +export default usePinchGesture; diff --git a/src/components/MultiGestureCanvas/useTapGestures.ts b/src/components/MultiGestureCanvas/useTapGestures.ts new file mode 100644 index 000000000000..ce67f11a91c8 --- /dev/null +++ b/src/components/MultiGestureCanvas/useTapGestures.ts @@ -0,0 +1,149 @@ +/* eslint-disable no-param-reassign */ +import {useMemo} from 'react'; +import type {TapGesture} from 'react-native-gesture-handler'; +import {Gesture} from 'react-native-gesture-handler'; +import {runOnJS, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import {DOUBLE_TAP_SCALE, SPRING_CONFIG} from './constants'; +import type {MultiGestureCanvasVariables} from './types'; +import * as MultiGestureCanvasUtils from './utils'; + +type UseTapGesturesProps = Pick< + MultiGestureCanvasVariables, + 'canvasSize' | 'contentSize' | 'minContentScale' | 'maxContentScale' | 'offsetX' | 'offsetY' | 'pinchScale' | 'zoomScale' | 'reset' | 'stopAnimation' | 'onScaleChanged' | 'onTap' +>; + +const useTapGestures = ({ + canvasSize, + contentSize, + minContentScale, + maxContentScale, + offsetX, + offsetY, + pinchScale, + zoomScale, + reset, + stopAnimation, + onScaleChanged, + onTap, +}: UseTapGesturesProps): {singleTapGesture: TapGesture; doubleTapGesture: TapGesture} => { + // The content size after scaling it with minimum scale to fit the content into the canvas + const scaledContentWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]); + const scaledContentHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]); + + // On double tap the content should be zoomed to fill, but at least zoomed by DOUBLE_TAP_SCALE + const doubleTapScale = useMemo(() => Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale), [maxContentScale, minContentScale]); + + const zoomToCoordinates = useWorkletCallback( + (focalX: number, focalY: number, callback: () => void) => { + 'worklet'; + + stopAnimation(); + + // By how much the canvas is bigger than the content horizontally and vertically per side + const horizontalCanvasOffset = Math.max(0, (canvasSize.width - scaledContentWidth) / 2); + const verticalCanvasOffset = Math.max(0, (canvasSize.height - scaledContentHeight) / 2); + + // We need to adjust the focal point to take into account the canvas offset + // The focal point cannot be outside of the content's bounds + const adjustedFocalPoint = { + x: MultiGestureCanvasUtils.clamp(focalX - horizontalCanvasOffset, 0, scaledContentWidth), + y: MultiGestureCanvasUtils.clamp(focalY - verticalCanvasOffset, 0, scaledContentHeight), + }; + + // The center of the canvas + const canvasCenter = { + x: canvasSize.width / 2, + y: canvasSize.height / 2, + }; + + // The center of the content before zooming + const originalContentCenter = { + x: scaledContentWidth / 2, + y: scaledContentHeight / 2, + }; + + // The size of the content after zooming + const zoomedContentSize = { + width: scaledContentWidth * doubleTapScale, + height: scaledContentHeight * doubleTapScale, + }; + + // The center of the zoomed content + const zoomedContentCenter = { + x: zoomedContentSize.width / 2, + y: zoomedContentSize.height / 2, + }; + + // By how much the zoomed content is bigger/smaller than the canvas. + const zoomedContentOffset = { + x: zoomedContentCenter.x - canvasCenter.x, + y: zoomedContentCenter.y - canvasCenter.y, + }; + + // How much the content needs to be shifted based on the focal point + const shiftingFactor = { + x: adjustedFocalPoint.x / originalContentCenter.x - 1, + y: adjustedFocalPoint.y / originalContentCenter.y - 1, + }; + + // The offset after applying the focal point adjusted shift. + // We need to invert the shift, because the content is moving in the opposite direction (* -1) + const offsetAfterZooming = { + x: zoomedContentOffset.x * (shiftingFactor.x * -1), + y: zoomedContentOffset.y * (shiftingFactor.y * -1), + }; + + // If the zoomed content is less tall than the canvas, we need to reset the vertical offset + if (zoomedContentSize.height < canvasSize.height) { + offsetAfterZooming.y = 0; + } + + offsetX.value = withSpring(offsetAfterZooming.x, SPRING_CONFIG); + offsetY.value = withSpring(offsetAfterZooming.y, SPRING_CONFIG); + zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG, callback); + pinchScale.value = doubleTapScale; + }, + [scaledContentWidth, scaledContentHeight, canvasSize, doubleTapScale], + ); + + const doubleTapGesture = Gesture.Tap() + .numberOfTaps(2) + .maxDelay(150) + .maxDistance(20) + .onEnd((evt) => { + const triggerScaleChangedEvent = () => { + 'worklet'; + + if (onScaleChanged != null) { + runOnJS(onScaleChanged)(zoomScale.value); + } + }; + + // If the content is already zoomed, we want to reset the zoom, + // otherwise we want to zoom in + if (zoomScale.value > 1) { + reset(true, triggerScaleChangedEvent); + } else { + zoomToCoordinates(evt.x, evt.y, triggerScaleChangedEvent); + } + }); + + const singleTapGesture = Gesture.Tap() + .numberOfTaps(1) + .maxDuration(125) + .onBegin(() => { + stopAnimation(); + }) + // eslint-disable-next-line @typescript-eslint/naming-convention + .onFinalize((_evt, success) => { + if (!success || onTap === undefined) { + return; + } + + runOnJS(onTap)(); + }); + + return {singleTapGesture, doubleTapGesture}; +}; + +export default useTapGestures; diff --git a/src/components/MultiGestureCanvas/utils.ts b/src/components/MultiGestureCanvas/utils.ts new file mode 100644 index 000000000000..e5688489c048 --- /dev/null +++ b/src/components/MultiGestureCanvas/utils.ts @@ -0,0 +1,22 @@ +import type {CanvasSize, ContentSize} from './types'; + +type GetCanvasFitScale = (props: {canvasSize: CanvasSize; contentSize: ContentSize}) => {scaleX: number; scaleY: number; minScale: number; maxScale: number}; + +const getCanvasFitScale: GetCanvasFitScale = ({canvasSize, contentSize}) => { + const scaleX = canvasSize.width / contentSize.width; + const scaleY = canvasSize.height / contentSize.height; + + const minScale = Math.min(scaleX, scaleY); + const maxScale = Math.max(scaleX, scaleY); + + return {scaleX, scaleY, minScale, maxScale}; +}; + +/** Clamps a value between a lower and upper bound */ +function clamp(value: number, lowerBound: number, upperBound: number) { + 'worklet'; + + return Math.min(Math.max(lowerBound, value), upperBound); +} + +export {getCanvasFitScale, clamp}; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index b01d6d85f135..a0f8f52927b9 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1008,6 +1008,17 @@ function getTransparentColor(color: string) { return `${color}00`; } +function getOpacityStyle(opacity: number) { + return {opacity}; +} + +function getMultiGestureCanvasContainerStyle(canvasWidth: number): ViewStyle { + return { + width: canvasWidth, + overflow: 'hidden', + }; +} + const staticStyleUtils = { positioning, combineStyles, @@ -1071,6 +1082,8 @@ const staticStyleUtils = { getEReceiptColorCode, getNavigationModalCardStyle, getCardStyles, + getOpacityStyle, + getMultiGestureCanvasContainerStyle, }; const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({