From a790c0e15a138457f8a182781121484c248d7389 Mon Sep 17 00:00:00 2001 From: Someshwar Tripathi Date: Wed, 6 Dec 2023 03:59:23 +0530 Subject: [PATCH 001/382] Add confirmation modal when user cancels a task --- src/pages/home/HeaderView.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 5b57419c8530..8cb504a0c4b0 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -19,6 +19,7 @@ import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; import Text from '@components/Text'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; import Tooltip from '@components/Tooltip'; +import ConfirmModal from '@components/ConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {getGroupChatName} from '@libs/GroupChatUtils'; @@ -80,6 +81,7 @@ const defaultProps = { }; function HeaderView(props) { + const [isCancelTaskConfirmModalVisible, setIsCancelTaskConfirmModalVisible] = React.useState(false); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); const {translate} = useLocalize(); const theme = useTheme(); @@ -128,7 +130,7 @@ function HeaderView(props) { threeDotMenuItems.push({ icon: Expensicons.Trashcan, text: translate('common.cancel'), - onSelected: Session.checkIfActionIsAllowed(() => Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum)), + onSelected: () => setIsCancelTaskConfirmModalVisible(true), }); } } @@ -283,6 +285,19 @@ function HeaderView(props) { )} + { + setIsCancelTaskConfirmModalVisible(false); + Session.checkIfActionIsAllowed(Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum)); + }} + onCancel={() => setIsCancelTaskConfirmModalVisible(false)} + title={translate('task.cancelTask')} + prompt={translate('task.cancelConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> )} From 5a4037cdc37ef1982040258faf53537b4ee96538 Mon Sep 17 00:00:00 2001 From: Someshwar Tripathi Date: Wed, 6 Dec 2023 03:59:54 +0530 Subject: [PATCH 002/382] Add text strings to language files --- src/languages/en.ts | 2 ++ src/languages/es.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index 817f06f6b344..30f87c625044 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1679,6 +1679,8 @@ export default { markAsIncomplete: 'Mark as incomplete', assigneeError: 'There was an error assigning this task, please try another assignee.', genericCreateTaskFailureMessage: 'Unexpected error create task, please try again later.', + cancelTask: 'Cancel task', + cancelConfirmation: 'Are you sure that you want to cancel this task?', }, statementPage: { title: (year, monthName) => `${monthName} ${year} statement`, diff --git a/src/languages/es.ts b/src/languages/es.ts index b219021daa0f..3f7e24602124 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1703,6 +1703,8 @@ export default { markAsIncomplete: 'Marcar como incompleta', assigneeError: 'Hubo un error al asignar esta tarea, inténtalo con otro usuario.', genericCreateTaskFailureMessage: 'Error inesperado al crear el tarea, por favor, inténtalo más tarde.', + cancelTask: 'Cancelar tarea', + cancelConfirmation: '¿Estás seguro de que quieres cancelar esta tarea?', }, statementPage: { title: (year, monthName) => `Estado de cuenta de ${monthName} ${year}`, From 8e85d34d0b7525509132d4a730deca89bc46561d Mon Sep 17 00:00:00 2001 From: Someshwar Tripathi Date: Wed, 6 Dec 2023 04:04:16 +0530 Subject: [PATCH 003/382] Prettier changes --- src/pages/home/HeaderView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 8cb504a0c4b0..f0c6a44fca8e 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import GoogleMeetIcon from '@assets/images/google-meet.svg'; import ZoomIcon from '@assets/images/zoom-icon.svg'; +import ConfirmModal from '@components/ConfirmModal'; import DisplayNames from '@components/DisplayNames'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -19,7 +20,6 @@ import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; import Text from '@components/Text'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; import Tooltip from '@components/Tooltip'; -import ConfirmModal from '@components/ConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {getGroupChatName} from '@libs/GroupChatUtils'; From f623dbbdb4bccfd48b4df1d46d3ddb130909a4cd Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Wed, 13 Dec 2023 14:59:09 +0700 Subject: [PATCH 004/382] use set method when creating policy for payment --- src/libs/actions/Policy.js | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index f33e6637e2de..430caaf6d6b4 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -1568,12 +1568,12 @@ function createWorkspaceFromIOUPayment(iouReport) { const optimisticData = [ { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: newWorkspace, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, value: { [sessionAccountID]: { @@ -1587,7 +1587,7 @@ function createWorkspaceFromIOUPayment(iouReport) { }, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, value: { pendingFields: { @@ -1597,12 +1597,12 @@ function createWorkspaceFromIOUPayment(iouReport) { }, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, value: announceReportActionData, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, value: { pendingFields: { @@ -1612,12 +1612,12 @@ function createWorkspaceFromIOUPayment(iouReport) { }, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, value: adminsReportActionData, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`, value: { pendingFields: { @@ -1627,17 +1627,17 @@ function createWorkspaceFromIOUPayment(iouReport) { }, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, value: workspaceChatReportActionData, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, value: null, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`, value: null, }, @@ -1712,37 +1712,37 @@ function createWorkspaceFromIOUPayment(iouReport) { const failureData = [ { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, value: null, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, value: null, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, value: null, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, value: null, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, value: null, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`, value: null, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, value: null, }, From 15b35b34b614ed683effb4db754aa80a6d15ed2d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 14 Dec 2023 17:16:36 +0100 Subject: [PATCH 005/382] start migration --- .../Pager/AttachmentCarouselPagerContext.js | 5 - .../AttachmentCarousel/Pager/index.js | 172 ----- src/components/Lightbox.js | 2 +- .../MultiGestureCanvas/getCanvasFitScale.ts | 22 - src/components/MultiGestureCanvas/index.js | 602 ------------------ 5 files changed, 1 insertion(+), 802 deletions(-) delete mode 100644 src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js delete mode 100644 src/components/Attachments/AttachmentCarousel/Pager/index.js delete mode 100644 src/components/MultiGestureCanvas/getCanvasFitScale.ts delete mode 100644 src/components/MultiGestureCanvas/index.js 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/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/Lightbox.js b/src/components/Lightbox.js index d0d5a1653242..0f570d6d0d01 100644 --- a/src/components/Lightbox.js +++ b/src/components/Lightbox.js @@ -6,8 +6,8 @@ import useStyleUtils from '@styles/useStyleUtils'; 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'; +import getCanvasFitScale from './MultiGestureCanvas/utils'; // Increase/decrease this number to change the number of concurrent lightboxes // The more concurrent lighboxes, the worse performance gets (especially on low-end devices) 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 c5fd2632c22d..000000000000 --- a/src/components/MultiGestureCanvas/index.js +++ /dev/null @@ -1,602 +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 pinchGesture = Gesture.Pinch() - .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) => { - 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}; From 83df84983451c7d95a5b9f7a0c93541cda08a98b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 14 Dec 2023 17:16:42 +0100 Subject: [PATCH 006/382] continue --- .../Pager/AttachmentCarouselPagerContext.ts | 18 + .../AttachmentCarousel/Pager/index.tsx | 174 +++++ src/components/MultiGestureCanvas/index.tsx | 623 ++++++++++++++++++ src/components/MultiGestureCanvas/utils.ts | 42 ++ 4 files changed, 857 insertions(+) create mode 100644 src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts create mode 100644 src/components/Attachments/AttachmentCarousel/Pager/index.tsx create mode 100644 src/components/MultiGestureCanvas/index.tsx create mode 100644 src/components/MultiGestureCanvas/utils.ts diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts new file mode 100644 index 000000000000..6c19d1ccdafe --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts @@ -0,0 +1,18 @@ +import {createContext} from 'react'; +import PagerView from 'react-native-pager-view'; +import {SharedValue} from 'react-native-reanimated'; + +type AttachmentCarouselPagerContextType = { + onTap: () => void; + onSwipe: (y: number) => void; + onSwipeSuccess: () => void; + onPinchGestureChange: (isPinchGestureInUse: boolean) => void; + pagerRef: React.Ref; + shouldPagerScroll: SharedValue; + isScrolling: SharedValue; +}; + +const AttachmentCarouselPagerContext = createContext(null); + +export default AttachmentCarouselPagerContext; +export type {AttachmentCarouselPagerContextType}; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx new file mode 100644 index 000000000000..7043579edd3c --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -0,0 +1,174 @@ +import _ from 'lodash'; +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, {PagerViewProps} from 'react-native-pager-view'; +import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useEvent, useHandler, useSharedValue} from 'react-native-reanimated'; +import refPropTypes from '@components/refPropTypes'; +import useThemeStyles from '@hooks/useThemeStyles'; +import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext'; + +const AnimatedPagerView = Animated.createAnimatedComponent(createNativeWrapper(PagerView)); + +type PageScrollHandler = NonNullable; +type PageScrollHandlerParams = Parameters; +const usePageScrollHandler = (handlers: PageScrollHandlerParams[0], dependencies: PageScrollHandlerParams[1]): PageScrollHandler => { + 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/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx new file mode 100644 index 000000000000..224c0c41c23a --- /dev/null +++ b/src/components/MultiGestureCanvas/index.tsx @@ -0,0 +1,623 @@ +import React, {useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import PagerView from 'react-native-pager-view'; +import Animated, { + cancelAnimation, + runOnJS, + runOnUI, + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + useWorkletCallback, + withDecay, + withSpring, +} from 'react-native-reanimated'; +import AttachmentCarouselPagerContext, {AttachmentCarouselPagerContextType} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {clamp, getCanvasFitScale, getDeepDefaultProps} from './utils'; + +const DOUBLE_TAP_SCALE = 3; + +const defaultZoomRange = { + min: 1, + max: 20, +}; + +const zoomScaleBounceFactors = { + min: 0.7, + max: 1.5, +}; + +const SPRING_CONFIG = { + mass: 1, + stiffness: 1000, + damping: 500, +}; + +type MultiGestureCanvasProps = React.PropsWithChildren<{ + /** + * Wheter the canvas is currently active (in the screen) or not. + * Disables certain gestures and functionality + */ + isActive: boolean; + + /** Handles scale changed event */ + onScaleChanged: (zoomScale: number) => void; + + /** The width and height of the canvas. + * This is needed in order to properly scale the content in the canvas + */ + canvasSize: { + width: number; + height: number; + }; + + /** The width and height of the content. + * This is needed in order to properly scale the content in the canvas + */ + contentSize: { + width: number; + height: number; + }; + + /** Range of zoom that can be applied to the content by pinching or double tapping. */ + zoomRange: { + min?: number; + max?: number; + }; +}>; + +function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, children, ...props}: MultiGestureCanvasProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {contentSize, zoomRange} = getDeepDefaultProps(props); + + const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); + + const pagerRefFallback = useRef(null); + + const defaultContext: AttachmentCarouselPagerContextType = { + onTap: () => undefined, + onSwipe: () => undefined, + onSwipeSuccess: () => undefined, + onPinchGestureChange: () => undefined, + pagerRef: pagerRefFallback, + shouldPagerScroll: useSharedValue(false), + isScrolling: useSharedValue(false), + ...props, + }; + const {onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isScrolling, onPinchGestureChange} = attachmentCarouselPagerContext ?? defaultContext; + + 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: number, canvasFocalY: number) => { + '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<{ + x: number; + y: number; + } | null>(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: number) => { + 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: number, focalY: number) => ({ + 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 pinchGesture = Gesture.Pinch() + .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) => { + 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( + () => ({scale: zoomScale.value, isRunning: pinchGestureRunning.value}), + ({scale, isRunning}) => { + const newIsPinchGestureInUse = scale !== 1 || isRunning; + 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.displayName = 'MultiGestureCanvas'; + +export default MultiGestureCanvas; +export {defaultZoomRange, zoomScaleBounceFactors}; diff --git a/src/components/MultiGestureCanvas/utils.ts b/src/components/MultiGestureCanvas/utils.ts new file mode 100644 index 000000000000..697ca5e65ba5 --- /dev/null +++ b/src/components/MultiGestureCanvas/utils.ts @@ -0,0 +1,42 @@ +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}; +}; + +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}; +} + +export {getCanvasFitScale, clamp, getDeepDefaultProps}; From 3a8a606720b787cff978e1d8a554fb348d654a0d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 14 Dec 2023 17:39:57 +0100 Subject: [PATCH 007/382] further type stuff --- .../AttachmentCarousel/Pager/index.tsx | 58 ++++++++------- .../Pager/usePageScrollHandler.ts | 24 ++++++ .../MultiGestureCanvas/constants.ts | 11 +++ src/components/MultiGestureCanvas/index.tsx | 26 ++----- .../MultiGestureCanvas/propTypes.js | 73 ------------------- src/components/MultiGestureCanvas/types.ts | 11 +++ src/components/MultiGestureCanvas/utils.ts | 25 +++++-- 7 files changed, 102 insertions(+), 126 deletions(-) create mode 100644 src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts create mode 100644 src/components/MultiGestureCanvas/constants.ts delete mode 100644 src/components/MultiGestureCanvas/propTypes.js create mode 100644 src/components/MultiGestureCanvas/types.ts diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index 7043579edd3c..d483b59a6edb 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -3,34 +3,15 @@ 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, {PagerViewProps} from 'react-native-pager-view'; -import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useEvent, useHandler, useSharedValue} from 'react-native-reanimated'; +import PagerView from 'react-native-pager-view'; +import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useSharedValue} from 'react-native-reanimated'; import refPropTypes from '@components/refPropTypes'; import useThemeStyles from '@hooks/useThemeStyles'; import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext'; +import usePageScrollHandler from './usePageScrollHandler'; const AnimatedPagerView = Animated.createAnimatedComponent(createNativeWrapper(PagerView)); -type PageScrollHandler = NonNullable; -type PageScrollHandlerParams = Parameters; -const usePageScrollHandler = (handlers: PageScrollHandlerParams[0], dependencies: PageScrollHandlerParams[1]): PageScrollHandler => { - 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'; @@ -66,7 +47,34 @@ const pagerDefaultProps = { forwardedRef: null, }; -function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelected, onTap, onSwipe = noopWorklet, onSwipeSuccess, onSwipeDown, onPinchGestureChange, forwardedRef}) { +type AttachmentCarouselPagerProps = React.PropsWithChildren<{ + items: Array<{ + key: string; + url: string; + }>; + renderItem: () => React.ReactNode; + initialIndex: number; + onPageSelected: () => void; + onTap: () => void; + onSwipe: () => void; + onSwipeSuccess: () => void; + onSwipeDown: () => void; + onPinchGestureChange: () => void; + forwardedRef: React.Ref; +}>; + +function AttachmentCarouselPager({ + items, + renderItem, + initialIndex, + onPageSelected, + onTap, + onSwipe = noopWorklet, + onSwipeSuccess, + onSwipeDown, + onPinchGestureChange, + forwardedRef, +}: AttachmentCarouselPagerProps) { const styles = useThemeStyles(); const shouldPagerScroll = useSharedValue(true); const pagerRef = useRef(null); @@ -144,7 +152,7 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte style={styles.flex1} initialPage={initialIndex} > - {_.map(items, (item, index) => ( + {items.map((item, index) => ( ( diff --git a/src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts b/src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts new file mode 100644 index 000000000000..e65f1ff3cd00 --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts @@ -0,0 +1,24 @@ +import {PagerViewProps} from 'react-native-pager-view'; +import {useEvent, useHandler} from 'react-native-reanimated'; + +type PageScrollHandler = NonNullable; +type PageScrollHandlerParams = Parameters; +const usePageScrollHandler = (handlers: PageScrollHandlerParams[0], dependencies: PageScrollHandlerParams[1]): PageScrollHandler => { + 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, + ); +}; + +export default usePageScrollHandler; diff --git a/src/components/MultiGestureCanvas/constants.ts b/src/components/MultiGestureCanvas/constants.ts new file mode 100644 index 000000000000..0103d07c55c2 --- /dev/null +++ b/src/components/MultiGestureCanvas/constants.ts @@ -0,0 +1,11 @@ +const defaultZoomRange = { + min: 1, + max: 20, +}; + +const zoomScaleBounceFactors = { + min: 0.7, + max: 1.5, +}; + +export {defaultZoomRange, zoomScaleBounceFactors}; diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index 224c0c41c23a..17e2eeb417e0 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -17,20 +17,12 @@ import Animated, { import AttachmentCarouselPagerContext, {AttachmentCarouselPagerContextType} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import {zoomScaleBounceFactors} from './constants'; +import {ContentSizeProp, ZoomRangeProp} from './types'; import {clamp, getCanvasFitScale, getDeepDefaultProps} from './utils'; const DOUBLE_TAP_SCALE = 3; -const defaultZoomRange = { - min: 1, - max: 20, -}; - -const zoomScaleBounceFactors = { - min: 0.7, - max: 1.5, -}; - const SPRING_CONFIG = { mass: 1, stiffness: 1000, @@ -58,22 +50,16 @@ type MultiGestureCanvasProps = React.PropsWithChildren<{ /** The width and height of the content. * This is needed in order to properly scale the content in the canvas */ - contentSize: { - width: number; - height: number; - }; + contentSize: ContentSizeProp; /** Range of zoom that can be applied to the content by pinching or double tapping. */ - zoomRange: { - min?: number; - max?: number; - }; + zoomRange?: ZoomRangeProp; }>; function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, children, ...props}: MultiGestureCanvasProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {contentSize, zoomRange} = getDeepDefaultProps(props); + const {contentSize, zoomRange} = getDeepDefaultProps({contentSize: props.contentSize, zoomRange: props.zoomRange}); const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); @@ -620,4 +606,4 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr MultiGestureCanvas.displayName = 'MultiGestureCanvas'; export default MultiGestureCanvas; -export {defaultZoomRange, zoomScaleBounceFactors}; +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 new file mode 100644 index 000000000000..11dfc767aacf --- /dev/null +++ b/src/components/MultiGestureCanvas/types.ts @@ -0,0 +1,11 @@ +type ContentSizeProp = { + width: number; + height: number; +}; + +type ZoomRangeProp = { + min?: number; + max?: number; +}; + +export type {ContentSizeProp, ZoomRangeProp}; diff --git a/src/components/MultiGestureCanvas/utils.ts b/src/components/MultiGestureCanvas/utils.ts index 697ca5e65ba5..85cc59887fd5 100644 --- a/src/components/MultiGestureCanvas/utils.ts +++ b/src/components/MultiGestureCanvas/utils.ts @@ -1,3 +1,6 @@ +import {defaultZoomRange} from './constants'; +import {ContentSizeProp, ZoomRangeProp} from './types'; + type GetCanvasFitScale = (props: { canvasSize: { width: number; @@ -19,24 +22,32 @@ const getCanvasFitScale: GetCanvasFitScale = ({canvasSize, contentSize}) => { return {scaleX, scaleY, minScale, maxScale}; }; -function clamp(value, lowerBound, upperBound) { +function clamp(value: number, lowerBound: number, upperBound: number) { 'worklet'; return Math.min(Math.max(lowerBound, value), upperBound); } -function getDeepDefaultProps({contentSize: contentSizeProp = {}, zoomRange: zoomRangeProp = {}}) { +type Props = { + contentSize?: ContentSizeProp; + zoomRange?: ZoomRangeProp; +}; +type PropsWithDefault = { + contentSize: ContentSizeProp; + zoomRange: Required; +}; +const getDeepDefaultProps = ({contentSize: contentSizeProp, zoomRange: zoomRangeProp}: Props): PropsWithDefault => { const contentSize = { - width: contentSizeProp.width == null ? 1 : contentSizeProp.width, - height: contentSizeProp.height == null ? 1 : contentSizeProp.height, + width: contentSizeProp?.width ?? 1, + height: contentSizeProp?.height ?? 1, }; const zoomRange = { - min: zoomRangeProp.min == null ? defaultZoomRange.min : zoomRangeProp.min, - max: zoomRangeProp.max == null ? defaultZoomRange.max : zoomRangeProp.max, + min: zoomRangeProp?.min ?? defaultZoomRange.min, + max: zoomRangeProp?.max ?? defaultZoomRange.max, }; return {contentSize, zoomRange}; -} +}; export {getCanvasFitScale, clamp, getDeepDefaultProps}; From 9d22a45679e58d5e1638beb5a0e367dce05f6909 Mon Sep 17 00:00:00 2001 From: tienifr Date: Wed, 20 Dec 2023 14:27:29 +0700 Subject: [PATCH 008/382] fix: 33318 --- src/components/ThemeProvider.tsx | 7 +++- src/libs/DomUtils/index.native.ts | 6 ++++ src/libs/DomUtils/index.ts | 57 +++++++++++++++++++++++++++++++ web/index.html | 26 -------------- 4 files changed, 69 insertions(+), 27 deletions(-) diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 34bc32be9c99..41423991f051 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -1,10 +1,11 @@ /* eslint-disable react/jsx-props-no-spreading */ import PropTypes from 'prop-types'; -import React, {useMemo} from 'react'; +import React, {useEffect, useMemo} from 'react'; import useThemePreferenceWithStaticOverride from '@hooks/useThemePreferenceWithStaticOverride'; import themes from '@styles/theme'; import ThemeContext from '@styles/theme/context/ThemeContext'; import {ThemePreferenceWithoutSystem} from '@styles/theme/types'; +import DomUtils from '@libs/DomUtils'; const propTypes = { /** Rendered child component */ @@ -20,6 +21,10 @@ function ThemeProvider({children, theme: staticThemePreference}: ThemeProviderPr const theme = useMemo(() => themes[themePreference], [themePreference]); + useEffect(() => { + DomUtils.addCSS(DomUtils.getAutofilledInputStyle(theme.text), 'autofill-input') + }, [theme.text]); + return {children}; } diff --git a/src/libs/DomUtils/index.native.ts b/src/libs/DomUtils/index.native.ts index 0864f1a16ac0..f161e0eeeeb2 100644 --- a/src/libs/DomUtils/index.native.ts +++ b/src/libs/DomUtils/index.native.ts @@ -2,6 +2,10 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => null; +const addCSS = () => null; + +const getAutofilledInputStyle = () => null; + const requestAnimationFrame = (callback: () => void) => { if (!callback) { return; @@ -11,6 +15,8 @@ const requestAnimationFrame = (callback: () => void) => { }; export default { + addCSS, + getAutofilledInputStyle, getActiveElement, requestAnimationFrame, }; diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index 6a2eed57fbe6..001d57745f53 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -2,7 +2,64 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => document.activeElement; +const addCSS = (css: string, styleId: string) => { + var head = document.getElementsByTagName('head')[0]; + var existingStyle = document.getElementById(styleId); + + if (existingStyle) { + // If style tag with the specified ID exists, update its content + if (existingStyle.styleSheet) { // IE + existingStyle.styleSheet.cssText = css; + } else { // the world + existingStyle.innerHTML = css; + } + } else { + // If style tag doesn't exist, create a new one + var s = document.createElement('style'); + s.setAttribute("id", styleId); + s.setAttribute('type', 'text/css'); + + if (s.styleSheet) { // IE + s.styleSheet.cssText = css; + } else { // the world + s.appendChild(document.createTextNode(css)); + } + + head.appendChild(s); + } +} + +/* Customizes the background and text colors for autofill inputs in Chrome */ +/* Chrome on iOS does not support the autofill pseudo class because it is a non-standard webkit feature. +We should rely on the chrome-autofilled property being added to the input when users use auto-fill */ +const getAutofilledInputStyle = (inputTextColor: string) => ` + input[chrome-autofilled], + input[chrome-autofilled]:hover, + input[chrome-autofilled]:focus, + textarea[chrome-autofilled], + textarea[chrome-autofilled]:hover, + textarea[chrome-autofilled]:focus, + select[chrome-autofilled], + select[chrome-autofilled]:hover, + select[chrome-autofilled]:focus, + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + textarea:-webkit-autofill, + textarea:-webkit-autofill:hover, + textarea:-webkit-autofill:focus, + select:-webkit-autofill, + select:-webkit-autofill:hover, + select:-webkit-autofill:focus { + -webkit-background-clip: text; + -webkit-text-fill-color: ${inputTextColor}; + caret-color: ${inputTextColor}; + } +`; + export default { + addCSS, + getAutofilledInputStyle, getActiveElement, requestAnimationFrame: window.requestAnimationFrame.bind(window), }; diff --git a/web/index.html b/web/index.html index 967873fe586c..e83f4527a1a3 100644 --- a/web/index.html +++ b/web/index.html @@ -70,32 +70,6 @@ display: none; } - /* Customizes the background and text colors for autofill inputs in Chrome */ - /* Chrome on iOS does not support the autofill pseudo class because it is a non-standard webkit feature. - We should rely on the chrome-autofilled property being added to the input when users use auto-fill */ - input[chrome-autofilled], - input[chrome-autofilled]:hover, - input[chrome-autofilled]:focus, - textarea[chrome-autofilled], - textarea[chrome-autofilled]:hover, - textarea[chrome-autofilled]:focus, - select[chrome-autofilled], - select[chrome-autofilled]:hover, - select[chrome-autofilled]:focus, - input:-webkit-autofill, - input:-webkit-autofill:hover, - input:-webkit-autofill:focus, - textarea:-webkit-autofill, - textarea:-webkit-autofill:hover, - textarea:-webkit-autofill:focus, - select:-webkit-autofill, - select:-webkit-autofill:hover, - select:-webkit-autofill:focus { - -webkit-background-clip: text; - -webkit-text-fill-color: #ffffff; - caret-color: #ffffff; - } - /* Prevent autofill from overlapping with the input label in Chrome */ div:has(input:-webkit-autofill, input[chrome-autofilled]) > label { transform: translateY(var(--active-label-translate-y)) scale(var(--active-label-scale)) !important; From 81142e7cf9191e19c461e8704c27f2fcbe54117a Mon Sep 17 00:00:00 2001 From: tienifr Date: Fri, 22 Dec 2023 15:54:36 +0700 Subject: [PATCH 009/382] fix ts error --- src/components/ThemeProvider.tsx | 4 ++-- src/libs/DomUtils/index.ts | 40 +++++++++++++++++--------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 41423991f051..5fe9bfec1e4a 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -2,10 +2,10 @@ import PropTypes from 'prop-types'; import React, {useEffect, useMemo} from 'react'; import useThemePreferenceWithStaticOverride from '@hooks/useThemePreferenceWithStaticOverride'; +import DomUtils from '@libs/DomUtils'; import themes from '@styles/theme'; import ThemeContext from '@styles/theme/context/ThemeContext'; import {ThemePreferenceWithoutSystem} from '@styles/theme/types'; -import DomUtils from '@libs/DomUtils'; const propTypes = { /** Rendered child component */ @@ -22,7 +22,7 @@ function ThemeProvider({children, theme: staticThemePreference}: ThemeProviderPr const theme = useMemo(() => themes[themePreference], [themePreference]); useEffect(() => { - DomUtils.addCSS(DomUtils.getAutofilledInputStyle(theme.text), 'autofill-input') + DomUtils.addCSS(DomUtils.getAutofilledInputStyle(theme.text), 'autofill-input'); }, [theme.text]); return {children}; diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index 001d57745f53..17dce79cf503 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -3,35 +3,37 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => document.activeElement; const addCSS = (css: string, styleId: string) => { - var head = document.getElementsByTagName('head')[0]; - var existingStyle = document.getElementById(styleId); + const existingStyle = document.getElementById(styleId); if (existingStyle) { - // If style tag with the specified ID exists, update its content - if (existingStyle.styleSheet) { // IE - existingStyle.styleSheet.cssText = css; - } else { // the world + if ('styleSheet' in existingStyle) { + // Supports IE8 and below + (existingStyle.styleSheet as any).cssText = css; + } else { existingStyle.innerHTML = css; } } else { - // If style tag doesn't exist, create a new one - var s = document.createElement('style'); - s.setAttribute("id", styleId); - s.setAttribute('type', 'text/css'); + const styleElement = document.createElement('style'); + styleElement.setAttribute('id', styleId); + styleElement.setAttribute('type', 'text/css'); - if (s.styleSheet) { // IE - s.styleSheet.cssText = css; - } else { // the world - s.appendChild(document.createTextNode(css)); + if ('styleSheet' in styleElement) { + // Supports IE8 and below + (styleElement.styleSheet as any).cssText = css; + } else { + styleElement.appendChild(document.createTextNode(css)); } - head.appendChild(s); + const head = document.getElementsByTagName('head')[0]; + head.appendChild(styleElement); } -} +}; -/* Customizes the background and text colors for autofill inputs in Chrome */ -/* Chrome on iOS does not support the autofill pseudo class because it is a non-standard webkit feature. -We should rely on the chrome-autofilled property being added to the input when users use auto-fill */ +/** + * Customizes the background and text colors for autofill inputs in Chrome + * Chrome on iOS does not support the autofill pseudo class because it is a non-standard webkit feature. + * We should rely on the chrome-autofilled property being added to the input when users use auto-fill + */ const getAutofilledInputStyle = (inputTextColor: string) => ` input[chrome-autofilled], input[chrome-autofilled]:hover, From d5ba5b5f9d82cb2fa7027466de5ac7864dad41c9 Mon Sep 17 00:00:00 2001 From: tienifr Date: Fri, 22 Dec 2023 16:17:26 +0700 Subject: [PATCH 010/382] fix lint --- src/libs/DomUtils/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index 17dce79cf503..25700ca015bb 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -8,6 +8,7 @@ const addCSS = (css: string, styleId: string) => { if (existingStyle) { if ('styleSheet' in existingStyle) { // Supports IE8 and below + // eslint-disable-next-line @typescript-eslint/no-explicit-any (existingStyle.styleSheet as any).cssText = css; } else { existingStyle.innerHTML = css; @@ -19,6 +20,7 @@ const addCSS = (css: string, styleId: string) => { if ('styleSheet' in styleElement) { // Supports IE8 and below + // eslint-disable-next-line @typescript-eslint/no-explicit-any (styleElement.styleSheet as any).cssText = css; } else { styleElement.appendChild(document.createTextNode(css)); From 06acbcd4c0aabb6056df4c6c39bb446ef8c5fd04 Mon Sep 17 00:00:00 2001 From: tienifr Date: Wed, 27 Dec 2023 12:19:43 +0700 Subject: [PATCH 011/382] fix: timer in login page restarts --- src/pages/signin/SignInPage.js | 13 +++++++------ .../signin/ValidateCodeForm/BaseValidateCodeForm.js | 2 ++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index 8cb0ef9907af..fc221d841036 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -262,14 +262,15 @@ function SignInPageInner({credentials, account, isInModal, activeClients, prefer blurOnSubmit={account.validated === false} scrollPageToTop={signInPageLayoutRef.current && signInPageLayoutRef.current.scrollPageToTop} /> + {shouldShowValidateCodeForm && ( + + )} {isClientTheLeader && ( <> - {shouldShowValidateCodeForm && ( - - )} {shouldShowUnlinkLoginForm && } {shouldShowChooseSSOOrMagicCode && } {shouldShowEmailDeliveryFailurePage && } diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 03db6d3436cf..3130965ecc99 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -13,6 +13,7 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import withToggleVisibilityView from '@components/withToggleVisibilityView'; import usePrevious from '@hooks/usePrevious'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -417,4 +418,5 @@ export default compose( session: {key: ONYXKEYS.SESSION}, }), withNetwork(), + withToggleVisibilityView, )(BaseValidateCodeForm); From 1da2e9a5e97000034c8926287b8c1a149d407182 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 28 Dec 2023 14:04:43 +0100 Subject: [PATCH 012/382] fix: better gestures --- src/components/MultiGestureCanvas/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index c5fd2632c22d..9c98f2780b05 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -578,7 +578,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr }, ]} > - + Date: Fri, 29 Dec 2023 13:10:20 +0100 Subject: [PATCH 013/382] further improve variable names --- src/components/MultiGestureCanvas/index.js | 66 +++++++++++----------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index 9c98f2780b05..6ff38a15981a 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -87,35 +87,33 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr 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); + // pan and pinch gesture const offsetX = useSharedValue(0); const offsetY = useSharedValue(0); + + // pan gesture + const panTranslateX = useSharedValue(0); + const panTranslateY = useSharedValue(0); const isSwiping = useSharedValue(false); + // pan velocity to calculate the decay + const panVelocityX = useSharedValue(0); + const panVelocityY = useSharedValue(0); + // disable pan vertically when content is smaller than screen + const canPanVertically = useDerivedValue(() => canvasSize.height < zoomScaledContentHeight.value, [canvasSize.height]); - // used for moving fingers when pinching + // pinch gesture 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 = { + // scale in between gestures + const pinchScaleOffset = useSharedValue(1); + // origin of the pinch gesture + const pinchOrigin = { 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 @@ -153,14 +151,14 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr }; }, [canvasSize.width, canvasSize.height]); - const afterPanGesture = useWorkletCallback(() => { + const returnToBoundaries = 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) { + if (zoomScale.value === 1 && offsetX.value === 0 && offsetY.value === 0 && panTranslateX.value === 0 && panTranslateY.value === 0) { // we don't need to run any animations return; } @@ -285,8 +283,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr zoomScale.value = withSpring(1, SPRING_CONFIG); } else { zoomScale.value = 1; - translateX.value = 0; - translateY.value = 0; + panTranslateX.value = 0; + panTranslateY.value = 0; offsetX.value = 0; offsetY.value = 0; pinchTranslateX.value = 0; @@ -379,11 +377,11 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr panVelocityY.value = evt.velocityY; if (!isSwiping.value) { - translateX.value += evt.changeX; + panTranslateX.value += evt.changeX; } if (canPanVertically.value || isSwiping.value) { - translateY.value += evt.changeY; + panTranslateY.value += evt.changeY; } }) .onEnd((evt) => { @@ -393,10 +391,10 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr return; } - offsetX.value += translateX.value; - offsetY.value += translateY.value; - translateX.value = 0; - translateY.value = 0; + offsetX.value += panTranslateX.value; + offsetY.value += panTranslateY.value; + panTranslateX.value = 0; + panTranslateY.value = 0; if (isSwiping.value) { const enoughVelocity = Math.abs(evt.velocityY) > 300 && Math.abs(evt.velocityX) < Math.abs(evt.velocityY); @@ -427,7 +425,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr } } - afterPanGesture(); + returnToBoundaries(); panVelocityX.value = 0; panVelocityY.value = 0; @@ -462,8 +460,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const adjustFocal = getAdjustedFocal(evt.focalX, evt.focalY); - origin.x.value = adjustFocal.x; - origin.y.value = adjustFocal.y; + pinchOrigin.x.value = adjustFocal.x; + pinchOrigin.y.value = adjustFocal.y; }) .onChange((evt) => { const newZoomScale = pinchScaleOffset.value * evt.scale; @@ -474,8 +472,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr } 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; + const newPinchTranslateX = adjustedFocal.x + pinchGestureScale.value * pinchOrigin.x.value * -1; + const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * pinchOrigin.y.value * -1; if (zoomScale.value >= zoomRange.min && zoomScale.value <= zoomRange.max) { pinchTranslateX.value = newPinchTranslateX; @@ -527,8 +525,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr 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; + const x = pinchTranslateX.value + pinchBounceTranslateX.value + panTranslateX.value + offsetX.value; + const y = pinchTranslateY.value + pinchBounceTranslateY.value + panTranslateY.value + offsetY.value; if (isSwiping.value) { onSwipe(y); From 4c0d2c571df616d2ddcc31e36411cb7ef5b327e2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 29 Dec 2023 13:59:43 +0100 Subject: [PATCH 014/382] extract gesture code to hooks --- src/components/MultiGestureCanvas/index.js | 519 +++--------------- .../MultiGestureCanvas/usePanGesture.js | 237 ++++++++ .../MultiGestureCanvas/usePinchGesture.js | 115 ++++ .../MultiGestureCanvas/useTapGestures.js | 127 +++++ src/components/MultiGestureCanvas/utils.ts | 19 + 5 files changed, 581 insertions(+), 436 deletions(-) create mode 100644 src/components/MultiGestureCanvas/usePanGesture.js create mode 100644 src/components/MultiGestureCanvas/usePinchGesture.js create mode 100644 src/components/MultiGestureCanvas/useTapGestures.js create mode 100644 src/components/MultiGestureCanvas/utils.ts diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index 6ff38a15981a..1e8f70c9665f 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -1,42 +1,19 @@ 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 Animated, {cancelAnimation, runOnJS, runOnUI, useAnimatedReaction, 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 getCanvasFitScale from './getCanvasFitScale'; import {defaultZoomRange, multiGestureCanvasDefaultProps, multiGestureCanvasPropTypes} from './propTypes'; +import usePanGesture from './usePanGesture'; +import usePinchGesture from './usePinchGesture'; +import useTapGestures from './useTapGestures'; +import * as MultiGestureCanvasUtils from './utils'; -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); -} +const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; +const zoomScaleBounceFactors = MultiGestureCanvasUtils.zoomScaleBounceFactors; function getDeepDefaultProps({contentSize: contentSizeProp = {}, zoomRange: zoomRangeProp = {}}) { const contentSize = { @@ -72,11 +49,6 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr }; 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 @@ -84,9 +56,6 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr // 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]); - // pan and pinch gesture const offsetX = useSharedValue(0); const offsetY = useSharedValue(0); @@ -95,11 +64,6 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const panTranslateX = useSharedValue(0); const panTranslateY = useSharedValue(0); const isSwiping = useSharedValue(false); - // pan velocity to calculate the decay - const panVelocityX = useSharedValue(0); - const panVelocityY = useSharedValue(0); - // disable pan vertically when content is smaller than screen - const canPanVertically = useDerivedValue(() => canvasSize.height < zoomScaledContentHeight.value, [canvasSize.height]); // pinch gesture const pinchTranslateX = useSharedValue(0); @@ -108,170 +72,12 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const pinchBounceTranslateY = useSharedValue(0); // scale in between gestures const pinchScaleOffset = useSharedValue(1); - // origin of the pinch gesture - const pinchOrigin = { - x: useSharedValue(0), - y: 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 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 returnToBoundaries = 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 && panTranslateX.value === 0 && panTranslateY.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; @@ -282,235 +88,76 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr offsetY.value = withSpring(0, SPRING_CONFIG); zoomScale.value = withSpring(1, SPRING_CONFIG); } else { + offsetX.value = 0; + offsetY.value = 0; zoomScale.value = 1; panTranslateX.value = 0; panTranslateY.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) { - panTranslateX.value += evt.changeX; - } - - if (canPanVertically.value || isSwiping.value) { - panTranslateY.value += evt.changeY; - } - }) - .onEnd((evt) => { - previousTouch.value = null; - - if (isScrolling.value) { - return; - } - - offsetX.value += panTranslateX.value; - offsetY.value += panTranslateY.value; - panTranslateX.value = 0; - panTranslateY.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; - } - } - - returnToBoundaries(); + const {singleTap, doubleTap} = useTapGestures({ + canvasSize, + contentSize, + minContentScale, + maxContentScale, + panGestureRef, + offsetX, + offsetY, + pinchScaleOffset, + zoomScale, + reset, + stopAnimation, + onScaleChanged, + onTap, + }); - panVelocityX.value = 0; - panVelocityY.value = 0; - }) - .withRef(panGestureRef); + const panGesture = usePanGesture({ + canvasSize, + contentSize, + panGestureRef, + pagerRef, + singleTap, + doubleTap, + zoomScale, + zoomRange, + totalScale, + offsetX, + offsetY, + panTranslateX, + panTranslateY, + isSwiping, + isScrolling, + onSwipeSuccess, + stopAnimation, + }); - const getAdjustedFocal = useWorkletCallback( - (focalX, focalY) => ({ - x: focalX - (canvasSize.width / 2 + offsetX.value), - y: focalY - (canvasSize.height / 2 + offsetY.value), - }), - [canvasSize.width, canvasSize.height], - ); + const pinchGesture = usePinchGesture({ + canvasSize, + contentSize, + panGestureRef, + pagerRef, + singleTap, + doubleTap, + zoomScale, + zoomRange, + totalScale, + offsetX, + offsetY, + panTranslateX, + panTranslateY, + isSwiping, + isScrolling, + onSwipeSuccess, + stopAnimation, + }); - // used to store event scale value when we limit scale - const pinchGestureScale = useSharedValue(1); + // Triggers "onPinchGestureChange" callback when pinch scale changes const pinchGestureRunning = useSharedValue(false); - const pinchGesture = Gesture.Pinch() - .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); - - pinchOrigin.x.value = adjustFocal.x; - pinchOrigin.y.value = adjustFocal.y; - }) - .onChange((evt) => { - 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 * pinchOrigin.x.value * -1; - const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * pinchOrigin.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], @@ -524,6 +171,26 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => onPinchGestureChange(isPinchGestureInUse), [isPinchGestureInUse]); + // 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]); + const animatedStyles = useAnimatedStyle(() => { const x = pinchTranslateX.value + pinchBounceTranslateX.value + panTranslateX.value + offsetX.value; const y = pinchTranslateY.value + pinchBounceTranslateY.value + panTranslateY.value + offsetY.value; @@ -545,26 +212,6 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr }; }); - // 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 ( { + const zoomScaledContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]); + const zoomScaledContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]); + + // pan velocity to calculate the decay + const panVelocityX = useSharedValue(0); + const panVelocityY = useSharedValue(0); + // disable pan vertically when content is smaller than screen + const canPanVertically = useDerivedValue(() => canvasSize.height < zoomScaledContentHeight.value, [canvasSize.height]); + + const previousTouch = useSharedValue(null); + + // 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 returnToBoundaries = useWorkletCallback(() => { + const {target, isInBoundaryX, isInBoundaryY, minVector, maxVector} = getBounds(); + + if (zoomScale.value === zoomRange.min && offsetX.value === 0 && offsetY.value === 0 && panTranslateX.value === 0 && panTranslateY.value === 0) { + // we don't need to run any animations + return; + } + + if (zoomScale.value <= zoomRange.min) { + // just center it + offsetX.value = withSpring(0, SPRING_CONFIG); + offsetY.value = withSpring(0, SPRING_CONFIG); + return; + } + + if (isInBoundaryX) { + if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= zoomRange.max) { + offsetX.value = withDecay({ + velocity: panVelocityX.value, + clamp: [minVector.x, maxVector.x], + deceleration: PAN_DECAY_DECELARATION, + rubberBandEffect: false, + }); + } + } else { + offsetX.value = withSpring(target.x, SPRING_CONFIG); + } + + if (!canPanVertically.value) { + offsetY.value = withSpring(target.y, SPRING_CONFIG); + } else 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: PAN_DECAY_DECELARATION, + }); + } + } else { + offsetY.value = withSpring(target.y, SPRING_CONFIG, () => { + isSwiping.value = false; + }); + } + }); + + 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, singleTap, doubleTap) + .onBegin(() => { + 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 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) { + panTranslateX.value += evt.changeX; + } + + if (canPanVertically.value || isSwiping.value) { + panTranslateY.value += evt.changeY; + } + }) + .onEnd((evt) => { + previousTouch.value = null; + + if (isScrolling.value) { + return; + } + + offsetX.value += panTranslateX.value; + offsetY.value += panTranslateY.value; + panTranslateX.value = 0; + panTranslateY.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; + } + } + + returnToBoundaries(); + + panVelocityX.value = 0; + panVelocityY.value = 0; + }) + .withRef(panGestureRef); + + return panGesture; +}; + +export default usePanGesture; diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js new file mode 100644 index 000000000000..9a7c8b9cf7b7 --- /dev/null +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -0,0 +1,115 @@ +/* eslint-disable no-param-reassign */ +import {Gesture} from 'react-native-gesture-handler'; +import {runOnJS, useSharedValue, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import * as MultiGestureCanvasUtils from './utils'; + +const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; +const zoomScaleBounceFactors = MultiGestureCanvasUtils.zoomScaleBounceFactors; + +const usePinchGesture = ({ + canvasSize, + singleTap, + doubleTap, + panGesture, + zoomScale, + zoomRange, + offsetX, + offsetY, + pinchTranslateX, + pinchTranslateY, + pinchBounceTranslateX, + pinchBounceTranslateY, + pinchScaleOffset, + pinchGestureRunning, + isScrolling, + stopAnimation, + onScaleChanged, +}) => { + // used to store event scale value when we limit scale + const pinchGestureScale = useSharedValue(1); + + // origin of the pinch gesture + const pinchOrigin = { + x: useSharedValue(0), + y: useSharedValue(0), + }; + + const getAdjustedFocal = useWorkletCallback( + (focalX, focalY) => ({ + x: focalX - (canvasSize.width / 2 + offsetX.value), + y: focalY - (canvasSize.height / 2 + offsetY.value), + }), + [canvasSize.width, canvasSize.height], + ); + const pinchGesture = Gesture.Pinch() + .onTouchesDown((evt, state) => { + // we don't want to activate pinch gesture when we are scrolling pager + if (!isScrolling.value) { + return; + } + + state.fail(); + }) + .simultaneousWithExternalGesture(panGesture, singleTap, doubleTap) + .onStart((evt) => { + pinchGestureRunning.value = true; + + stopAnimation(); + + const adjustFocal = getAdjustedFocal(evt.focalX, evt.focalY); + + pinchOrigin.x.value = adjustFocal.x; + pinchOrigin.y.value = adjustFocal.y; + }) + .onChange((evt) => { + 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 * pinchOrigin.x.value * -1; + const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * pinchOrigin.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); + } + }); + + return pinchGesture; +}; + +export default usePinchGesture; diff --git a/src/components/MultiGestureCanvas/useTapGestures.js b/src/components/MultiGestureCanvas/useTapGestures.js new file mode 100644 index 000000000000..0af5076618a4 --- /dev/null +++ b/src/components/MultiGestureCanvas/useTapGestures.js @@ -0,0 +1,127 @@ +/* eslint-disable no-param-reassign */ +import {useMemo} from 'react'; +import {Gesture} from 'react-native-gesture-handler'; +import {runOnJS, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import * as MultiGestureCanvasUtils from './utils'; + +const clamp = MultiGestureCanvasUtils.clamp; +const DOUBLE_TAP_SCALE = MultiGestureCanvasUtils.DOUBLE_TAP_SCALE; +const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; + +const useTapGestures = ({ + canvasSize, + contentSize, + minContentScale, + maxContentScale, + panGestureRef, + offsetX, + offsetY, + pinchScaleOffset, + zoomScale, + reset, + stopAnimation, + onScaleChanged, + onTap, +}) => { + 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 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 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 singleTap = Gesture.Tap() + .numberOfTaps(1) + .maxDuration(50) + .requireExternalGestureToFail(doubleTap, panGestureRef) + .onBegin(() => { + stopAnimation(); + }) + .onFinalize((_evt, success) => { + if (!success || !onTap) { + return; + } + + runOnJS(onTap)(); + }); + + return {singleTap, doubleTap}; +}; + +export default useTapGestures; diff --git a/src/components/MultiGestureCanvas/utils.ts b/src/components/MultiGestureCanvas/utils.ts new file mode 100644 index 000000000000..c28f02ed0f39 --- /dev/null +++ b/src/components/MultiGestureCanvas/utils.ts @@ -0,0 +1,19 @@ +const DOUBLE_TAP_SCALE = 3; + +const SPRING_CONFIG = { + mass: 1, + stiffness: 1000, + damping: 500, +}; + +const zoomScaleBounceFactors = { + min: 0.7, + max: 1.5, +}; +function clamp(value: number, lowerBound: number, upperBound: number) { + 'worklet'; + + return Math.min(Math.max(lowerBound, value), upperBound); +} + +export {clamp, DOUBLE_TAP_SCALE, SPRING_CONFIG, zoomScaleBounceFactors}; From 2632f75d6bcc0f336649054efa4052b453e099a1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 29 Dec 2023 14:07:43 +0100 Subject: [PATCH 015/382] further simplify --- src/components/MultiGestureCanvas/index.js | 40 ++++++------------- .../MultiGestureCanvas/usePinchGesture.js | 21 ++++++++-- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index 1e8f70c9665f..70713bf3d80c 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -1,7 +1,7 @@ -import React, {useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useContext, useEffect, useMemo, useRef} 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, withSpring} from 'react-native-reanimated'; +import Animated, {cancelAnimation, runOnUI, useAnimatedReaction, 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'; @@ -56,7 +56,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr // and not smaller than needed to fit const totalScale = useDerivedValue(() => zoomScale.value * minContentScale, [minContentScale]); - // pan and pinch gesture + // stored offset of the canvas (used for panning and pinching) const offsetX = useSharedValue(0); const offsetY = useSharedValue(0); @@ -64,6 +64,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const panTranslateX = useSharedValue(0); const panTranslateY = useSharedValue(0); const isSwiping = useSharedValue(false); + const panGestureRef = useRef(Gesture.Pan()); // pinch gesture const pinchTranslateX = useSharedValue(0); @@ -98,8 +99,6 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr } }); - const panGestureRef = useRef(Gesture.Pan()); - const {singleTap, doubleTap} = useTapGestures({ canvasSize, contentSize, @@ -138,39 +137,24 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const pinchGesture = usePinchGesture({ canvasSize, - contentSize, - panGestureRef, - pagerRef, singleTap, doubleTap, + panGesture, zoomScale, zoomRange, - totalScale, offsetX, offsetY, - panTranslateX, - panTranslateY, - isSwiping, + pinchTranslateX, + pinchTranslateY, + pinchBounceTranslateX, + pinchBounceTranslateY, + pinchScaleOffset, isScrolling, - onSwipeSuccess, stopAnimation, + onScaleChanged, + onPinchGestureChange, }); - // Triggers "onPinchGestureChange" callback when pinch scale changes - const pinchGestureRunning = useSharedValue(false); - 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]); - // reacts to scale change and enables/disables pager scroll useAnimatedReaction( () => zoomScale.value, diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 9a7c8b9cf7b7..544baa1d045e 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -1,6 +1,7 @@ /* eslint-disable no-param-reassign */ +import {useEffect, useState} from 'react'; import {Gesture} from 'react-native-gesture-handler'; -import {runOnJS, useSharedValue, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import {runOnJS, useAnimatedReaction, useSharedValue, useWorkletCallback, withSpring} from 'react-native-reanimated'; import * as MultiGestureCanvasUtils from './utils'; const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; @@ -20,14 +21,14 @@ const usePinchGesture = ({ pinchBounceTranslateX, pinchBounceTranslateY, pinchScaleOffset, - pinchGestureRunning, isScrolling, stopAnimation, onScaleChanged, + onPinchGestureChange, }) => { // used to store event scale value when we limit scale const pinchGestureScale = useSharedValue(1); - + const pinchGestureRunning = useSharedValue(false); // origin of the pinch gesture const pinchOrigin = { x: useSharedValue(0), @@ -109,6 +110,20 @@ const usePinchGesture = ({ } }); + // Triggers "onPinchGestureChange" callback when pinch scale changes + 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]); + return pinchGesture; }; From 141387187c0c429577f3787d07705763d693ce41 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 29 Dec 2023 14:26:00 +0100 Subject: [PATCH 016/382] improve styles in Lightbox --- src/components/Lightbox.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/Lightbox.js b/src/components/Lightbox.js index 06f8ee4cfeb6..0b09ed1e745a 100644 --- a/src/components/Lightbox.js +++ b/src/components/Lightbox.js @@ -168,7 +168,7 @@ function Lightbox({isAuthTokenRequired, source, onScaleChanged, onPress, onError {isContainerLoaded && ( <> {isLightboxVisible && ( - + setImageLoaded(true)} onLoad={(e) => { - const width = (e.nativeEvent?.width || 0) / PixelRatio.get(); - const height = (e.nativeEvent?.height || 0) / PixelRatio.get(); + const width = (e.nativeEvent?.width || 0) * PixelRatio.get(); + const height = (e.nativeEvent?.height || 0) * PixelRatio.get(); setImageDimensions({...imageDimensions, lightboxSize: {width, height}}); }} /> @@ -194,10 +194,7 @@ function Lightbox({isAuthTokenRequired, source, onScaleChanged, onPress, onError {/* 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; - const height = e.nativeEvent?.height || 0; + const width = (e.nativeEvent?.width || 0) * PixelRatio.get(); + const height = (e.nativeEvent?.height || 0) * PixelRatio.get(); if (imageDimensions?.lightboxSize != null) { return; From dae44fca62a988ebafa34489349c19dc6bdba624 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 29 Dec 2023 18:27:25 +0100 Subject: [PATCH 017/382] rename "isScrolling" prop --- .../AttachmentCarousel/Pager/index.js | 10 ++++---- src/components/MultiGestureCanvas/index.js | 14 +++++------ .../MultiGestureCanvas/usePanGesture.js | 21 +++++++++-------- .../MultiGestureCanvas/usePinchGesture.js | 23 ++++++++++++------- 4 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js index 553e963a3461..699e2fc812cc 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js @@ -69,7 +69,7 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte const shouldPagerScroll = useSharedValue(true); const pagerRef = useRef(null); - const isScrolling = useSharedValue(false); + const isSwipingHorizontally = useSharedValue(false); const activeIndex = useSharedValue(initialIndex); const pageScrollHandler = usePageScrollHandler( @@ -78,7 +78,7 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte 'worklet'; activeIndex.value = e.position; - isScrolling.value = e.offset !== 0; + isSwipingHorizontally.value = e.offset !== 0; }, }, [], @@ -94,7 +94,7 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte // we use reanimated for this since onPageSelected is called // in the middle of the pager animation useAnimatedReaction( - () => isScrolling.value, + () => isSwipingHorizontally.value, (stillScrolling) => { if (stillScrolling) { return; @@ -118,7 +118,7 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte const contextValue = useMemo( () => ({ - isScrolling, + isSwipingHorizontally, pagerRef, shouldPagerScroll, onPinchGestureChange, @@ -127,7 +127,7 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte onSwipeSuccess, onSwipeDown, }), - [isScrolling, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeSuccess, onSwipeDown], + [isSwipingHorizontally, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeSuccess, onSwipeDown], ); return ( diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index 70713bf3d80c..671426ba66e7 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -37,14 +37,14 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); const pagerRefFallback = useRef(null); - const {onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isScrolling, onPinchGestureChange} = attachmentCarouselPagerContext || { + const {onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isSwipingHorizontally, onPinchGestureChange} = attachmentCarouselPagerContext || { onTap: () => undefined, onSwipe: () => undefined, onSwipeSuccess: () => undefined, onPinchGestureChange: () => undefined, pagerRef: pagerRefFallback, shouldPagerScroll: false, - isScrolling: false, + isSwipingHorizontally: false, ...props, }; @@ -63,7 +63,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr // pan gesture const panTranslateX = useSharedValue(0); const panTranslateY = useSharedValue(0); - const isSwiping = useSharedValue(false); + const isSwipingVertically = useSharedValue(false); const panGestureRef = useRef(Gesture.Pan()); // pinch gesture @@ -129,8 +129,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr offsetY, panTranslateX, panTranslateY, - isSwiping, - isScrolling, + isSwipingHorizontally, + isSwipingVertically, onSwipeSuccess, stopAnimation, }); @@ -149,7 +149,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr pinchBounceTranslateX, pinchBounceTranslateY, pinchScaleOffset, - isScrolling, + isSwipingHorizontally, stopAnimation, onScaleChanged, onPinchGestureChange, @@ -179,7 +179,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const x = pinchTranslateX.value + pinchBounceTranslateX.value + panTranslateX.value + offsetX.value; const y = pinchTranslateY.value + pinchBounceTranslateY.value + panTranslateY.value + offsetY.value; - if (isSwiping.value) { + if (isSwipingVertically.value) { onSwipe(y); } diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index ddc887bcb3f4..c81af12ebb62 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -22,8 +22,8 @@ const usePanGesture = ({ offsetY, panTranslateX, panTranslateY, - isSwiping, - isScrolling, + isSwipingVertically, + isSwipingHorizontally, onSwipeSuccess, stopAnimation, }) => { @@ -121,7 +121,7 @@ const usePanGesture = ({ } } else { offsetY.value = withSpring(target.y, SPRING_CONFIG, () => { - isSwiping.value = false; + isSwipingVertically.value = false; }); } }); @@ -136,7 +136,7 @@ const usePanGesture = ({ // TODO: Swipe down to close carousel gesture // this needs fine tuning to work properly - // if (!isScrolling.value && scale.value === 1 && previousTouch.value != null) { + // if (!isSwipingHorizontally.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; @@ -167,7 +167,7 @@ const usePanGesture = ({ // since we're 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) { + if (evt.numberOfPointers > 1 || isSwipingHorizontally.value) { return; } @@ -175,27 +175,30 @@ const usePanGesture = ({ panVelocityY.value = evt.velocityY; - if (!isSwiping.value) { + if (!isSwipingVertically.value) { panTranslateX.value += evt.changeX; } - if (canPanVertically.value || isSwiping.value) { + if (canPanVertically.value || isSwipingVertically.value) { panTranslateY.value += evt.changeY; } }) .onEnd((evt) => { previousTouch.value = null; - if (isScrolling.value) { + // If we are swiping, we don't want to return to boundaries + if (isSwipingHorizontally.value) { return; } + // add pan translation to total offset offsetX.value += panTranslateX.value; offsetY.value += panTranslateY.value; + // reset pan gesture variables panTranslateX.value = 0; panTranslateY.value = 0; - if (isSwiping.value) { + if (isSwipingVertically.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); diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 544baa1d045e..4dbbdf53f3f0 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -21,7 +21,7 @@ const usePinchGesture = ({ pinchBounceTranslateX, pinchBounceTranslateY, pinchScaleOffset, - isScrolling, + isSwipingHorizontally, stopAnimation, onScaleChanged, onPinchGestureChange, @@ -45,7 +45,7 @@ const usePinchGesture = ({ const pinchGesture = Gesture.Pinch() .onTouchesDown((evt, state) => { // we don't want to activate pinch gesture when we are scrolling pager - if (!isScrolling.value) { + if (!isSwipingHorizontally.value) { return; } @@ -57,19 +57,21 @@ const usePinchGesture = ({ stopAnimation(); - const adjustFocal = getAdjustedFocal(evt.focalX, evt.focalY); + const adjustedFocal = getAdjustedFocal(evt.focalX, evt.focalY); - pinchOrigin.x.value = adjustFocal.x; - pinchOrigin.y.value = adjustFocal.y; + pinchOrigin.x.value = adjustedFocal.x; + pinchOrigin.y.value = adjustedFocal.y; }) .onChange((evt) => { const newZoomScale = pinchScaleOffset.value * evt.scale; + // limit zoom scale to zoom range and bounce if we go out of range if (zoomScale.value >= zoomRange.min * zoomScaleBounceFactors.min && zoomScale.value <= zoomRange.max * zoomScaleBounceFactors.max) { zoomScale.value = newZoomScale; pinchGestureScale.value = evt.scale; } + // calculate new pinch translation const adjustedFocal = getAdjustedFocal(evt.focalX, evt.focalY); const newPinchTranslateX = adjustedFocal.x + pinchGestureScale.value * pinchOrigin.x.value * -1; const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * pinchOrigin.y.value * -1; @@ -78,24 +80,29 @@ const usePinchGesture = ({ pinchTranslateX.value = newPinchTranslateX; pinchTranslateY.value = newPinchTranslateY; } else { + // Store x and y translation that is produced while bouncing to separate variables + // so that 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 offsetX.value += pinchTranslateX.value; offsetY.value += pinchTranslateY.value; + // Reset pinch gesture variables pinchTranslateX.value = 0; pinchTranslateY.value = 0; - pinchScaleOffset.value = zoomScale.value; pinchGestureScale.value = 1; - if (pinchScaleOffset.value < zoomRange.min) { + if (zoomScale.value < zoomRange.min) { pinchScaleOffset.value = zoomRange.min; zoomScale.value = withSpring(zoomRange.min, SPRING_CONFIG); - } else if (pinchScaleOffset.value > zoomRange.max) { + } else if (zoomScale.value > zoomRange.max) { pinchScaleOffset.value = zoomRange.max; zoomScale.value = withSpring(zoomRange.max, SPRING_CONFIG); + } else { + pinchScaleOffset.value = zoomScale.value; } if (pinchBounceTranslateX.value !== 0 || pinchBounceTranslateY.value !== 0) { From 7bcf3f5aa31a22d75eca5d659e8aaff21f938f6e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 29 Dec 2023 18:43:26 +0100 Subject: [PATCH 018/382] add more docs --- .../MultiGestureCanvas/usePanGesture.js | 20 +++++++++---------- .../MultiGestureCanvas/usePinchGesture.js | 12 +++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index c81af12ebb62..e8203f3bb75e 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -30,14 +30,13 @@ const usePanGesture = ({ const zoomScaledContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]); const zoomScaledContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]); + const previousTouch = useSharedValue(null); // pan velocity to calculate the decay const panVelocityX = useSharedValue(0); const panVelocityY = useSharedValue(0); // disable pan vertically when content is smaller than screen const canPanVertically = useDerivedValue(() => canvasSize.height < zoomScaledContentHeight.value, [canvasSize.height]); - const previousTouch = useSharedValue(null); - // calculates bounds of the scaled content // can we pan left/right/up/down // can be used to limit gesture or implementing tension effect @@ -167,6 +166,7 @@ const usePanGesture = ({ // since we're 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 + // we also need to prevent panning when we are swiping horizontally (in the pager) if (evt.numberOfPointers > 1 || isSwipingHorizontally.value) { return; } @@ -184,20 +184,16 @@ const usePanGesture = ({ } }) .onEnd((evt) => { - previousTouch.value = null; + // add pan translation to total offset + offsetX.value += panTranslateX.value; + offsetY.value += panTranslateY.value; // If we are swiping, we don't want to return to boundaries if (isSwipingHorizontally.value) { return; } - // add pan translation to total offset - offsetX.value += panTranslateX.value; - offsetY.value += panTranslateY.value; - // reset pan gesture variables - panTranslateX.value = 0; - panTranslateY.value = 0; - + // swipe to close animation when swiping down if (isSwipingVertically.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); @@ -229,8 +225,12 @@ const usePanGesture = ({ returnToBoundaries(); + // reset pan gesture variables panVelocityX.value = 0; panVelocityY.value = 0; + panTranslateX.value = 0; + panTranslateY.value = 0; + previousTouch.value = null; }) .withRef(panGestureRef); diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 4dbbdf53f3f0..cbfa525daae9 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -90,10 +90,6 @@ const usePinchGesture = ({ // Add pinch translation to total offset offsetX.value += pinchTranslateX.value; offsetY.value += pinchTranslateY.value; - // Reset pinch gesture variables - pinchTranslateX.value = 0; - pinchTranslateY.value = 0; - pinchGestureScale.value = 1; if (zoomScale.value < zoomRange.min) { pinchScaleOffset.value = zoomRange.min; @@ -110,11 +106,15 @@ const usePinchGesture = ({ pinchBounceTranslateY.value = withSpring(0, SPRING_CONFIG); } - pinchGestureRunning.value = false; - if (onScaleChanged != null) { runOnJS(onScaleChanged)(zoomScale.value); } + + // Reset pinch gesture variables + pinchGestureRunning.value = false; + pinchTranslateX.value = 0; + pinchTranslateY.value = 0; + pinchGestureScale.value = 1; }); // Triggers "onPinchGestureChange" callback when pinch scale changes From a08e2bb866b2f5dce29cd16f78d5160045cc099d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 29 Dec 2023 20:14:12 +0100 Subject: [PATCH 019/382] further improve component --- src/components/MultiGestureCanvas/index.js | 36 +++++++------- .../MultiGestureCanvas/usePanGesture.js | 47 ++++++++++--------- .../MultiGestureCanvas/usePinchGesture.js | 31 ++++++------ .../MultiGestureCanvas/useTapGestures.js | 11 +++-- src/components/MultiGestureCanvas/utils.ts | 4 +- 5 files changed, 65 insertions(+), 64 deletions(-) diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index 671426ba66e7..adbc46112621 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -56,9 +56,9 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr // and not smaller than needed to fit const totalScale = useDerivedValue(() => zoomScale.value * minContentScale, [minContentScale]); - // stored offset of the canvas (used for panning and pinching) - const offsetX = useSharedValue(0); - const offsetY = useSharedValue(0); + // total offset of the canvas (panning + pinching offset) + const totalOffsetX = useSharedValue(0); + const totalOffsetY = useSharedValue(0); // pan gesture const panTranslateX = useSharedValue(0); @@ -75,8 +75,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const pinchScaleOffset = useSharedValue(1); const stopAnimation = useWorkletCallback(() => { - cancelAnimation(offsetX); - cancelAnimation(offsetY); + cancelAnimation(totalOffsetX); + cancelAnimation(totalOffsetY); }); const reset = useWorkletCallback((animated) => { @@ -85,12 +85,12 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr stopAnimation(); if (animated) { - offsetX.value = withSpring(0, SPRING_CONFIG); - offsetY.value = withSpring(0, SPRING_CONFIG); + totalOffsetX.value = withSpring(0, SPRING_CONFIG); + totalOffsetY.value = withSpring(0, SPRING_CONFIG); zoomScale.value = withSpring(1, SPRING_CONFIG); } else { - offsetX.value = 0; - offsetY.value = 0; + totalOffsetX.value = 0; + totalOffsetY.value = 0; zoomScale.value = 1; panTranslateX.value = 0; panTranslateY.value = 0; @@ -105,8 +105,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr minContentScale, maxContentScale, panGestureRef, - offsetX, - offsetY, + totalOffsetX, + totalOffsetY, pinchScaleOffset, zoomScale, reset, @@ -125,8 +125,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr zoomScale, zoomRange, totalScale, - offsetX, - offsetY, + totalOffsetX, + totalOffsetY, panTranslateX, panTranslateY, isSwipingHorizontally, @@ -142,8 +142,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr panGesture, zoomScale, zoomRange, - offsetX, - offsetY, + totalOffsetX, + totalOffsetY, pinchTranslateX, pinchTranslateY, pinchBounceTranslateX, @@ -176,8 +176,10 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr }, [isActive, mounted, reset]); const animatedStyles = useAnimatedStyle(() => { - const x = pinchTranslateX.value + pinchBounceTranslateX.value + panTranslateX.value + offsetX.value; - const y = pinchTranslateY.value + pinchBounceTranslateY.value + panTranslateY.value + offsetY.value; + const x = pinchTranslateX.value + pinchBounceTranslateX.value + panTranslateX.value + totalOffsetX.value; + const y = pinchTranslateY.value + pinchBounceTranslateY.value + panTranslateY.value + totalOffsetY.value; + + // console.log({pinchTranslateY: pinchTranslateY.value, pinchBounceTranslateY: pinchBounceTranslateY.value, panTranslateY: panTranslateY.value, totalOffsetY: totalOffsetY.value}); if (isSwipingVertically.value) { onSwipe(y); diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index e8203f3bb75e..5d6279a8be56 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -18,8 +18,8 @@ const usePanGesture = ({ zoomScale, zoomRange, totalScale, - offsetX, - offsetY, + totalOffsetX, + totalOffsetY, panTranslateX, panTranslateY, isSwipingVertically, @@ -56,12 +56,12 @@ const usePanGesture = ({ const minVector = {x: -rightBoundary, y: -topBoundary}; const target = { - x: clamp(offsetX.value, minVector.x, maxVector.x), - y: clamp(offsetY.value, minVector.y, maxVector.y), + x: clamp(totalOffsetX.value, minVector.x, maxVector.x), + y: clamp(totalOffsetY.value, minVector.y, maxVector.y), }; - const isInBoundaryX = target.x === offsetX.value; - const isInBoundaryY = target.y === offsetY.value; + const isInBoundaryX = target.x === totalOffsetX.value; + const isInBoundaryY = target.y === totalOffsetY.value; return { target, @@ -77,21 +77,21 @@ const usePanGesture = ({ const returnToBoundaries = useWorkletCallback(() => { const {target, isInBoundaryX, isInBoundaryY, minVector, maxVector} = getBounds(); - if (zoomScale.value === zoomRange.min && offsetX.value === 0 && offsetY.value === 0 && panTranslateX.value === 0 && panTranslateY.value === 0) { + if (zoomScale.value === zoomRange.min && totalOffsetX.value === 0 && totalOffsetY.value === 0 && panTranslateX.value === 0 && panTranslateY.value === 0) { // we don't need to run any animations return; } if (zoomScale.value <= zoomRange.min) { // just center it - offsetX.value = withSpring(0, SPRING_CONFIG); - offsetY.value = withSpring(0, SPRING_CONFIG); + totalOffsetX.value = withSpring(0, SPRING_CONFIG); + totalOffsetY.value = withSpring(0, SPRING_CONFIG); return; } if (isInBoundaryX) { if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= zoomRange.max) { - offsetX.value = withDecay({ + totalOffsetX.value = withDecay({ velocity: panVelocityX.value, clamp: [minVector.x, maxVector.x], deceleration: PAN_DECAY_DECELARATION, @@ -99,27 +99,27 @@ const usePanGesture = ({ }); } } else { - offsetX.value = withSpring(target.x, SPRING_CONFIG); + totalOffsetX.value = withSpring(target.x, SPRING_CONFIG); } if (!canPanVertically.value) { - offsetY.value = withSpring(target.y, SPRING_CONFIG); + totalOffsetY.value = withSpring(target.y, SPRING_CONFIG); } else 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 + totalOffsetY.value !== minVector.y && + totalOffsetY.value !== maxVector.y ) { - offsetY.value = withDecay({ + totalOffsetY.value = withDecay({ velocity: panVelocityY.value, clamp: [minVector.y, maxVector.y], deceleration: PAN_DECAY_DECELARATION, }); } } else { - offsetY.value = withSpring(target.y, SPRING_CONFIG, () => { + totalOffsetY.value = withSpring(target.y, SPRING_CONFIG, () => { isSwipingVertically.value = false; }); } @@ -159,7 +159,7 @@ const usePanGesture = ({ } }) .simultaneousWithExternalGesture(pagerRef, singleTap, doubleTap) - .onBegin(() => { + .onStart(() => { stopAnimation(); }) .onChange((evt) => { @@ -185,8 +185,12 @@ const usePanGesture = ({ }) .onEnd((evt) => { // add pan translation to total offset - offsetX.value += panTranslateX.value; - offsetY.value += panTranslateY.value; + totalOffsetX.value += panTranslateX.value; + totalOffsetY.value += panTranslateY.value; + // reset pan gesture variables + panTranslateX.value = 0; + panTranslateY.value = 0; + previousTouch.value = null; // If we are swiping, we don't want to return to boundaries if (isSwipingHorizontally.value) { @@ -204,7 +208,7 @@ const usePanGesture = ({ return invert ? -v : v; }; - offsetY.value = withSpring( + totalOffsetY.value = withSpring( maybeInvert(contentSize.height * 2), { stiffness: 50, @@ -228,9 +232,6 @@ const usePanGesture = ({ // reset pan gesture variables panVelocityX.value = 0; panVelocityY.value = 0; - panTranslateX.value = 0; - panTranslateY.value = 0; - previousTouch.value = null; }) .withRef(panGestureRef); diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index cbfa525daae9..78aed77814cd 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -14,8 +14,8 @@ const usePinchGesture = ({ panGesture, zoomScale, zoomRange, - offsetX, - offsetY, + totalOffsetX, + totalOffsetY, pinchTranslateX, pinchTranslateY, pinchBounceTranslateX, @@ -26,9 +26,9 @@ const usePinchGesture = ({ onScaleChanged, onPinchGestureChange, }) => { + const isPinchGestureRunning = useSharedValue(false); // used to store event scale value when we limit scale const pinchGestureScale = useSharedValue(1); - const pinchGestureRunning = useSharedValue(false); // origin of the pinch gesture const pinchOrigin = { x: useSharedValue(0), @@ -37,8 +37,8 @@ const usePinchGesture = ({ const getAdjustedFocal = useWorkletCallback( (focalX, focalY) => ({ - x: focalX - (canvasSize.width / 2 + offsetX.value), - y: focalY - (canvasSize.height / 2 + offsetY.value), + x: focalX - (canvasSize.width / 2 + totalOffsetX.value), + y: focalY - (canvasSize.height / 2 + totalOffsetY.value), }), [canvasSize.width, canvasSize.height], ); @@ -53,7 +53,7 @@ const usePinchGesture = ({ }) .simultaneousWithExternalGesture(panGesture, singleTap, doubleTap) .onStart((evt) => { - pinchGestureRunning.value = true; + isPinchGestureRunning.value = true; stopAnimation(); @@ -88,8 +88,13 @@ const usePinchGesture = ({ }) .onEnd(() => { // Add pinch translation to total offset - offsetX.value += pinchTranslateX.value; - offsetY.value += pinchTranslateY.value; + totalOffsetX.value += pinchTranslateX.value; + totalOffsetY.value += pinchTranslateY.value; + // Reset pinch gesture variables + pinchTranslateX.value = 0; + pinchTranslateY.value = 0; + pinchGestureScale.value = 1; + isPinchGestureRunning.value = false; if (zoomScale.value < zoomRange.min) { pinchScaleOffset.value = zoomRange.min; @@ -107,20 +112,14 @@ const usePinchGesture = ({ } if (onScaleChanged != null) { - runOnJS(onScaleChanged)(zoomScale.value); + runOnJS(onScaleChanged)(pinchScaleOffset.value); } - - // Reset pinch gesture variables - pinchGestureRunning.value = false; - pinchTranslateX.value = 0; - pinchTranslateY.value = 0; - pinchGestureScale.value = 1; }); // Triggers "onPinchGestureChange" callback when pinch scale changes const [isPinchGestureInUse, setIsPinchGestureInUse] = useState(false); useAnimatedReaction( - () => [zoomScale.value, pinchGestureRunning.value], + () => [zoomScale.value, isPinchGestureRunning.value], ([zoom, running]) => { const newIsPinchGestureInUse = zoom !== 1 || running; if (isPinchGestureInUse !== newIsPinchGestureInUse) { diff --git a/src/components/MultiGestureCanvas/useTapGestures.js b/src/components/MultiGestureCanvas/useTapGestures.js index 0af5076618a4..a08fbc8fed4c 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.js +++ b/src/components/MultiGestureCanvas/useTapGestures.js @@ -4,8 +4,9 @@ import {Gesture} from 'react-native-gesture-handler'; import {runOnJS, useWorkletCallback, withSpring} from 'react-native-reanimated'; import * as MultiGestureCanvasUtils from './utils'; +const DOUBLE_TAP_SCALE = 3; + const clamp = MultiGestureCanvasUtils.clamp; -const DOUBLE_TAP_SCALE = MultiGestureCanvasUtils.DOUBLE_TAP_SCALE; const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; const useTapGestures = ({ @@ -14,8 +15,8 @@ const useTapGestures = ({ minContentScale, maxContentScale, panGestureRef, - offsetX, - offsetY, + totalOffsetX, + totalOffsetY, pinchScaleOffset, zoomScale, reset, @@ -82,8 +83,8 @@ const useTapGestures = ({ target.y = 0; } - offsetX.value = withSpring(target.x, SPRING_CONFIG); - offsetY.value = withSpring(target.y, SPRING_CONFIG); + totalOffsetX.value = withSpring(target.x, SPRING_CONFIG); + totalOffsetY.value = withSpring(target.y, SPRING_CONFIG); zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG); pinchScaleOffset.value = doubleTapScale; }, diff --git a/src/components/MultiGestureCanvas/utils.ts b/src/components/MultiGestureCanvas/utils.ts index c28f02ed0f39..da4c1133d237 100644 --- a/src/components/MultiGestureCanvas/utils.ts +++ b/src/components/MultiGestureCanvas/utils.ts @@ -1,5 +1,3 @@ -const DOUBLE_TAP_SCALE = 3; - const SPRING_CONFIG = { mass: 1, stiffness: 1000, @@ -16,4 +14,4 @@ function clamp(value: number, lowerBound: number, upperBound: number) { return Math.min(Math.max(lowerBound, value), upperBound); } -export {clamp, DOUBLE_TAP_SCALE, SPRING_CONFIG, zoomScaleBounceFactors}; +export {clamp, SPRING_CONFIG, zoomScaleBounceFactors}; From e5cd46fbe7bcd26fcdc365d86482ad961b9ed4ba Mon Sep 17 00:00:00 2001 From: Pedro Guerreiro Date: Tue, 2 Jan 2024 14:15:20 +0000 Subject: [PATCH 020/382] refactor(typescript): migrate validateloginpage --- src/libs/Navigation/Navigation.ts | 26 +++---- src/pages/ValidateLoginPage/index.js | 61 ----------------- src/pages/ValidateLoginPage/index.tsx | 42 ++++++++++++ .../{index.website.js => index.website.tsx} | 68 ++++++------------- 4 files changed, 75 insertions(+), 122 deletions(-) delete mode 100644 src/pages/ValidateLoginPage/index.js create mode 100644 src/pages/ValidateLoginPage/index.tsx rename src/pages/ValidateLoginPage/{index.website.js => index.website.tsx} (55%) diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 3552ff9e7410..2c250b6b89b2 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -1,18 +1,18 @@ -import {findFocusedRoute, getActionFromState} from '@react-navigation/core'; -import {CommonActions, EventArg, getPathFromState, NavigationContainerEventMap, NavigationState, PartialState, StackActions} from '@react-navigation/native'; +import { findFocusedRoute, getActionFromState } from '@react-navigation/core'; +import { CommonActions, EventArg, getPathFromState, NavigationContainerEventMap, NavigationState, PartialState, StackActions } from '@react-navigation/native'; import findLastIndex from 'lodash/findLastIndex'; import Log from '@libs/Log'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; -import ROUTES, {Route} from '@src/ROUTES'; -import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS'; +import ROUTES, { Route } from '@src/ROUTES'; +import SCREENS, { PROTECTED_SCREENS } from '@src/SCREENS'; import getStateFromPath from './getStateFromPath'; import originalGetTopmostReportActionId from './getTopmostReportActionID'; import originalGetTopmostReportId from './getTopmostReportId'; import linkingConfig from './linkingConfig'; import linkTo from './linkTo'; import navigationRef from './navigationRef'; -import {StackNavigationAction, StateOrRoute} from './types'; +import { StackNavigationAction, StateOrRoute } from './types'; let resolveNavigationIsReadyPromise: () => void; const navigationIsReadyPromise = new Promise((resolve) => { @@ -82,7 +82,7 @@ function getDistanceFromPathInRootNavigator(path: string): number { return index; } - currentState = {...currentState, routes: currentState.routes.slice(0, -1), index: currentState.index - 1}; + currentState = { ...currentState, routes: currentState.routes.slice(0, -1), index: currentState.index - 1 }; } return -1; @@ -123,7 +123,7 @@ function isActiveRoute(routePath: Route): boolean { * @param [type] - Type of action to perform. Currently UP is supported. */ function navigate(route: Route = ROUTES.HOME, type?: string) { - if (!canNavigate('navigate', {route})) { + if (!canNavigate('navigate', { route })) { // Store intended route if the navigator is not yet available, // we will try again after the NavigationContainer is ready Log.hmmm(`[Navigation] Container not yet ready, storing route as pending: ${route}`); @@ -138,7 +138,7 @@ function navigate(route: Route = ROUTES.HOME, type?: string) { * @param shouldEnforceFallback - Enforces navigation to fallback route * @param shouldPopToTop - Should we navigate to LHN on back press */ -function goBack(fallbackRoute: Route, shouldEnforceFallback = false, shouldPopToTop = false) { +function goBack(fallbackRoute?: Route, shouldEnforceFallback = false, shouldPopToTop = false) { if (!canNavigate('goBack')) { return; } @@ -173,7 +173,7 @@ function goBack(fallbackRoute: Route, shouldEnforceFallback = false, shouldPopTo } const isCentralPaneFocused = findFocusedRoute(navigationRef.current.getState())?.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR; - const distanceFromPathInRootNavigator = getDistanceFromPathInRootNavigator(fallbackRoute); + const distanceFromPathInRootNavigator = getDistanceFromPathInRootNavigator(fallbackRoute ?? ''); // Allow CentralPane to use UP with fallback route if the path is not found in root navigator. if (isCentralPaneFocused && fallbackRoute && distanceFromPathInRootNavigator === -1) { @@ -228,9 +228,9 @@ function dismissModal(targetReportID?: string) { } else if (targetReportID && rootState.routes.some((route) => route.name === SCREENS.NOT_FOUND)) { const lastRouteIndex = rootState.routes.length - 1; const centralRouteIndex = findLastIndex(rootState.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR); - navigationRef.current?.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: rootState.key}); + navigationRef.current?.dispatch({ ...StackActions.pop(lastRouteIndex - centralRouteIndex), target: rootState.key }); } else { - navigationRef.current?.dispatch({...StackActions.pop(), target: rootState.key}); + navigationRef.current?.dispatch({ ...StackActions.pop(), target: rootState.key }); } break; default: { @@ -315,7 +315,7 @@ function waitForProtectedRoutes() { return; } - const unsubscribe = navigationRef.current?.addListener('state', ({data}) => { + const unsubscribe = navigationRef.current?.addListener('state', ({ data }) => { const state = data?.state; if (navContainsProtectedRoutes(state)) { unsubscribe?.(); @@ -343,4 +343,4 @@ export default { waitForProtectedRoutes, }; -export {navigationRef}; +export { navigationRef }; diff --git a/src/pages/ValidateLoginPage/index.js b/src/pages/ValidateLoginPage/index.js deleted file mode 100644 index 6939ee07f665..000000000000 --- a/src/pages/ValidateLoginPage/index.js +++ /dev/null @@ -1,61 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useEffect} from 'react'; -import {withOnyx} from 'react-native-onyx'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import Navigation from '@libs/Navigation/Navigation'; -import * as Session from '@userActions/Session'; -import ONYXKEYS from '@src/ONYXKEYS'; -import {defaultProps as validateLinkDefaultProps, propTypes as validateLinkPropTypes} from './validateLinkPropTypes'; - -const propTypes = { - /** The accountID and validateCode are passed via the URL */ - route: validateLinkPropTypes, - - /** Session of currently logged in user */ - session: PropTypes.shape({ - /** Currently logged in user authToken */ - authToken: PropTypes.string, - }), - - /** The credentials of the person logging in */ - credentials: PropTypes.shape({ - /** The email the user logged in with */ - login: PropTypes.string, - }), -}; - -const defaultProps = { - route: validateLinkDefaultProps, - session: { - authToken: null, - }, - credentials: {}, -}; - -function ValidateLoginPage(props) { - useEffect(() => { - const accountID = lodashGet(props.route.params, 'accountID', ''); - const validateCode = lodashGet(props.route.params, 'validateCode', ''); - - if (lodashGet(props, 'session.authToken')) { - // If already signed in, do not show the validate code if not on web, - // because we don't want to block the user with the interstitial page. - Navigation.goBack(false); - } else { - Session.signInWithValidateCodeAndNavigate(accountID, validateCode); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ; -} - -ValidateLoginPage.defaultProps = defaultProps; -ValidateLoginPage.displayName = 'ValidateLoginPage'; -ValidateLoginPage.propTypes = propTypes; - -export default withOnyx({ - credentials: {key: ONYXKEYS.CREDENTIALS}, - session: {key: ONYXKEYS.SESSION}, -})(ValidateLoginPage); diff --git a/src/pages/ValidateLoginPage/index.tsx b/src/pages/ValidateLoginPage/index.tsx new file mode 100644 index 000000000000..597a025cf602 --- /dev/null +++ b/src/pages/ValidateLoginPage/index.tsx @@ -0,0 +1,42 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useEffect} from 'react'; +import {OnyxEntry, withOnyx} from 'react-native-onyx'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import Navigation from '@libs/Navigation/Navigation'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; +import * as Session from '@userActions/Session'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Session as SessionType} from '@src/types/onyx'; + +type ValidateLoginPageOnyxProps = { + session: OnyxEntry; +}; + +type ValidateLoginPageProps = ValidateLoginPageOnyxProps & StackScreenProps; + +function ValidateLoginPage({ + route: { + params: {accountID = '', validateCode = ''}, + }, + session, +}: ValidateLoginPageProps) { + useEffect(() => { + if (session?.authToken) { + // If already signed in, do not show the validate code if not on web, + // because we don't want to block the user with the interstitial page. + Navigation.goBack(); + } else { + Session.signInWithValidateCodeAndNavigate(Number(accountID), validateCode); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ; +} + +ValidateLoginPage.displayName = 'ValidateLoginPage'; + +export default withOnyx({ + session: {key: ONYXKEYS.SESSION}, +})(ValidateLoginPage); diff --git a/src/pages/ValidateLoginPage/index.website.js b/src/pages/ValidateLoginPage/index.website.tsx similarity index 55% rename from src/pages/ValidateLoginPage/index.website.js rename to src/pages/ValidateLoginPage/index.website.tsx index 677abd70f6db..1a68405934bc 100644 --- a/src/pages/ValidateLoginPage/index.website.js +++ b/src/pages/ValidateLoginPage/index.website.tsx @@ -1,60 +1,34 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; +import type {StackScreenProps} from '@react-navigation/stack'; import React, {useEffect} from 'react'; -import {withOnyx} from 'react-native-onyx'; +import {OnyxEntry, withOnyx} from 'react-native-onyx'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import ExpiredValidateCodeModal from '@components/ValidateCode/ExpiredValidateCodeModal'; import JustSignedInModal from '@components/ValidateCode/JustSignedInModal'; import ValidateCodeModal from '@components/ValidateCode/ValidateCodeModal'; import Navigation from '@libs/Navigation/Navigation'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {defaultProps as validateLinkDefaultProps, propTypes as validateLinkPropTypes} from './validateLinkPropTypes'; +import SCREENS from '@src/SCREENS'; +import type {Account, Credentials, Session as SessionType} from '@src/types/onyx'; -const propTypes = { - /** The accountID and validateCode are passed via the URL */ - route: validateLinkPropTypes, - - /** Session of currently logged in user */ - session: PropTypes.shape({ - /** Currently logged in user authToken */ - authToken: PropTypes.string, - }), - - /** The credentials of the person logging in */ - credentials: PropTypes.shape({ - /** The email the user logged in with */ - login: PropTypes.string, - - /** The validate code */ - validateCode: PropTypes.string, - }), - - /** The details about the account that the user is signing in with */ - account: PropTypes.shape({ - /** Whether a sign on form is loading (being submitted) */ - isLoading: PropTypes.bool, - }), +type ValidateLoginPageOnyxProps = { + account: OnyxEntry; + credentials: OnyxEntry; + session: OnyxEntry; }; -const defaultProps = { - route: validateLinkDefaultProps, - session: { - authToken: null, - }, - credentials: {}, - account: {}, -}; +type ValidateLoginPageProps = ValidateLoginPageOnyxProps & StackScreenProps; -function ValidateLoginPage(props) { - const login = lodashGet(props, 'credentials.login', null); - const autoAuthState = lodashGet(props, 'session.autoAuthState', CONST.AUTO_AUTH_STATE.NOT_STARTED); - const accountID = lodashGet(props.route.params, 'accountID', ''); - const validateCode = lodashGet(props.route.params, 'validateCode', ''); - const isSignedIn = Boolean(lodashGet(props, 'session.authToken', null)); - const is2FARequired = lodashGet(props, 'account.requiresTwoFactorAuth', false); - const cachedAccountID = lodashGet(props, 'credentials.accountID', null); +function ValidateLoginPage({account, credentials, route, session}: ValidateLoginPageProps) { + const login = credentials?.login; + const autoAuthState = session?.autoAuthState ?? CONST.AUTO_AUTH_STATE.NOT_STARTED; + const accountID = Number(route?.params.accountID ?? ''); + const validateCode = route.params.validateCode ?? ''; + const isSignedIn = !!session?.authToken; + const is2FARequired = !!account?.requiresTwoFactorAuth; + const cachedAccountID = credentials?.accountID; useEffect(() => { if (!login && isSignedIn && (autoAuthState === CONST.AUTO_AUTH_STATE.SIGNING_IN || autoAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN)) { @@ -74,7 +48,7 @@ function ValidateLoginPage(props) { }, []); useEffect(() => { - if (login || !cachedAccountID || !is2FARequired) { + if (!!login || !cachedAccountID || !is2FARequired) { return; } @@ -98,11 +72,9 @@ function ValidateLoginPage(props) { ); } -ValidateLoginPage.defaultProps = defaultProps; ValidateLoginPage.displayName = 'ValidateLoginPage'; -ValidateLoginPage.propTypes = propTypes; -export default withOnyx({ +export default withOnyx({ account: {key: ONYXKEYS.ACCOUNT}, credentials: {key: ONYXKEYS.CREDENTIALS}, session: {key: ONYXKEYS.SESSION}, From e9cb53c9171ac913e641928995ade1ff9ee5e061 Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Wed, 3 Jan 2024 08:50:02 +0100 Subject: [PATCH 021/382] start migrating MagicCodeInput to TypeScript --- src/CONST.ts | 5 + src/components/MagicCodeInput-draft.tsx | 418 ++++++++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 src/components/MagicCodeInput-draft.tsx diff --git a/src/CONST.ts b/src/CONST.ts index abba27b0c33b..30757a269166 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -903,6 +903,7 @@ const CONST = { KEYBOARD_TYPE: { VISIBLE_PASSWORD: 'visible-password', ASCII_CAPABLE: 'ascii-capable', + NUMBER_PAD: 'number-pad', }, INPUT_MODE: { @@ -2831,12 +2832,16 @@ const CONST = { CHECKBOX: 'checkbox', /** Use for elements that allow a choice from multiple options. */ COMBOBOX: 'combobox', + /** Use for form elements. */ + FORM: 'form', /** Use with scrollable lists to represent a grid layout. */ GRID: 'grid', /** Use for section headers or titles. */ HEADING: 'heading', /** Use for image elements. */ IMG: 'img', + /** Use for input elements. */ + INPUT: 'input', /** Use for elements that navigate to other pages or content. */ LINK: 'link', /** Use to identify a list of items. */ diff --git a/src/components/MagicCodeInput-draft.tsx b/src/components/MagicCodeInput-draft.tsx new file mode 100644 index 000000000000..6c1cb1851e18 --- /dev/null +++ b/src/components/MagicCodeInput-draft.tsx @@ -0,0 +1,418 @@ +import React, {ForwardedRef, forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {StyleSheet, View, TextInput as RNTextInput, NativeSyntheticEvent, TextInputFocusEventData} from 'react-native'; +import {HandlerStateChangeEvent, TapGestureHandler} from 'react-native-gesture-handler'; +import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Browser from '@libs/Browser'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import CONST from '@src/CONST'; +import FormHelpMessage from './FormHelpMessage'; +import Text from './Text'; +import TextInput from './TextInput'; + +const TEXT_INPUT_EMPTY_STATE = ''; + +type MagicCodeInputProps = { + /** Name attribute for the input */ + name?: string, + + /** Input value */ + value?: string, + + /** Should the input auto focus */ + autoFocus?: boolean, + + /** Whether we should wait before focusing the TextInput, useful when using transitions */ + shouldDelayFocus?: boolean, + + /** Error text to display */ + errorText?: string, + + /** Specifies autocomplete hints for the system, so it can provide autofill */ + autoComplete: 'sms-otp' | 'one-time-code' | 'off', + + /* Should submit when the input is complete */ + shouldSubmitOnComplete?: boolean, + + /** Function to call when the input is changed */ + onChangeText?: (value: string) => void, + + /** Function to call when the input is submitted or fully complete */ + onFulfill?: (value: string) => void, + + /** Specifies if the input has a validation error */ + hasError?: boolean, + + /** Specifies the max length of the input */ + maxLength?: number, + + /** Specifies if the keyboard should be disabled */ + isDisableKeyboard?: boolean, + + /** Last pressed digit on BigDigitPad */ + lastPressedDigit?: string, +} + +/** + * Converts a given string into an array of numbers that must have the same + * number of elements as the number of inputs. + */ +const decomposeString = (value: string, length: number): string[] => { + let arr = value.split('').slice(0, length).map((v) => (ValidationUtils.isNumeric(v) ? v : CONST.MAGIC_CODE_EMPTY_CHAR)) + if (arr.length < length) { + arr = arr.concat(Array(length - arr.length).fill(CONST.MAGIC_CODE_EMPTY_CHAR)); + } + return arr; +}; + +/** + * Converts an array of strings into a single string. If there are undefined or + * empty values, it will replace them with a space. + */ +const composeToString = (value: string[]): string => value.map((v) => (v === undefined || v === '' ? CONST.MAGIC_CODE_EMPTY_CHAR : v)).join(''); + +const getInputPlaceholderSlots = (length: number): number[] => Array.from(Array(length).keys()); + +function MagicCodeInput({ + value = '', + name = '', + autoFocus = true, + shouldDelayFocus = false, + errorText = '', + shouldSubmitOnComplete = true, + onChangeText: onChangeTextProp = () => {}, + onFulfill = () => {}, + hasError = false, + maxLength = CONST.MAGIC_CODE_LENGTH, + isDisableKeyboard = false, + lastPressedDigit = '', + autoComplete, +}: MagicCodeInputProps, ref: ForwardedRef) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const inputRefs = useRef(); + const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE); + const [focusedIndex, setFocusedIndex] = useState(0); + const [editIndex, setEditIndex] = useState(0); + const [wasSubmitted, setWasSubmitted] = useState(false); + const shouldFocusLast = useRef(false); + const inputWidth = useRef(0); + const lastFocusedIndex = useRef(0); + const lastValue = useRef(TEXT_INPUT_EMPTY_STATE); + + console.log("** I RENDER **") + + useEffect(() => { + lastValue.current = input.length; + }, [input]); + + const blurMagicCodeInput = () => { + inputRefs.current?.blur(); + setFocusedIndex(undefined); + }; + + const focusMagicCodeInput = () => { + setFocusedIndex(0); + lastFocusedIndex.current = 0; + setEditIndex(0); + inputRefs.current?.focus(); + }; + + const setInputAndIndex = (index: number | undefined) => { + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(index); + setEditIndex(index); + }; + + useImperativeHandle(ref, () => ({ + focus() { + focusMagicCodeInput(); + }, + focusLastSelected() { + inputRefs.current?.focus(); + }, + resetFocus() { + setInput(TEXT_INPUT_EMPTY_STATE); + focusMagicCodeInput(); + }, + clear() { + lastFocusedIndex.current = 0; + setInputAndIndex(0); + inputRefs.current?.focus(); + onChangeTextProp(''); + }, + blur() { + blurMagicCodeInput(); + }, + })); + + const validateAndSubmit = () => { + const numbers = decomposeString(value, maxLength); + if (wasSubmitted || !shouldSubmitOnComplete || numbers.filter((n) => ValidationUtils.isNumeric(n)).length !== maxLength || isOffline) { + return; + } + if (!wasSubmitted) { + setWasSubmitted(true); + } + // Blurs the input and removes focus from the last input and, if it should submit + // on complete, it will call the onFulfill callback. + blurMagicCodeInput(); + onFulfill(value); + lastValue.current = ''; + }; + + const {isOffline} = useNetwork({onReconnect: validateAndSubmit}); + + useEffect(() => { + validateAndSubmit(); + + // We have not added: + // + the editIndex as the dependency because we don't want to run this logic after focusing on an input to edit it after the user has completed the code. + // + the onFulfill as the dependency because onFulfill is changed when the preferred locale changed => avoid auto submit form when preferred locale changed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, shouldSubmitOnComplete]); + + /** + * Focuses on the input when it is pressed. + * + * @param event + * @param index + */ + const onFocus = (event: NativeSyntheticEvent) => { + if (shouldFocusLast.current) { + lastValue.current = TEXT_INPUT_EMPTY_STATE; + setInputAndIndex(lastFocusedIndex.current); + } + event.preventDefault(); + }; + + /** + * Callback for the onPress event, updates the indexes + * of the currently focused input. + * + * @param index + */ + const onPress = (index: number) => { + shouldFocusLast.current = false; + // TapGestureHandler works differently on mobile web and native app + // On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually + if (!Browser.isMobileChrome() && !Browser.isMobileSafari()) { + inputRefs.current?.focus(); + } + setInputAndIndex(index); + lastFocusedIndex.current = index; + }; + + /** + * Updates the magic inputs with the contents written in the + * input. It spreads each number into each input and updates + * the focused input on the next empty one, if exists. + * It handles both fast typing and only one digit at a time + * in a specific position. + * + * @param value + */ + const onChangeText = (val: string) => { + console.log('ON CHANGE', val) + if (!val || !ValidationUtils.isNumeric(val)) { + return; + } + + // Checks if one new character was added, or if the content was replaced + const hasToSlice = val.length - 1 === lastValue.current.length && val.slice(0, val.length - 1) === lastValue.current; + + // Gets the new value added by the user + const addedValue = hasToSlice ? val.slice(lastValue.current.length, val.length) : val; + + lastValue.current = val; + // Updates the focused input taking into consideration the last input + // edited and the number of digits added by the user. + const numbersArr = addedValue + .trim() + .split('') + .slice(0, maxLength - editIndex); + const updatedFocusedIndex = Math.min(editIndex + (numbersArr.length - 1) + 1, maxLength - 1); + + let numbers = decomposeString(val, maxLength); + numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, maxLength)]; + + setInputAndIndex(updatedFocusedIndex); + + const finalInput = composeToString(numbers); + onChangeTextProp(finalInput); + }; + + /** + * Handles logic related to certain key presses. + * + * NOTE: when using Android Emulator, this can only be tested using + * hardware keyboard inputs. + * + * @param event + */ + const onKeyPress = ({nativeEvent: {key: keyValue}}) => { + if (keyValue === 'Backspace' || keyValue === '<') { + let numbers = decomposeString(value, maxLength); + + // If keyboard is disabled and no input is focused we need to remove + // the last entered digit and focus on the correct input + if (isDisableKeyboard && focusedIndex === undefined) { + const indexBeforeLastEditIndex = editIndex === 0 ? editIndex : editIndex - 1; + + const indexToFocus = numbers[editIndex] === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex; + inputRefs.current[indexToFocus].focus(); + onChangeTextProp(value.substring(0, indexToFocus)); + + return; + } + + // If the currently focused index already has a value, it will delete + // that value but maintain the focus on the same input. + if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { + setInput(TEXT_INPUT_EMPTY_STATE); + numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, maxLength)]; + setEditIndex(focusedIndex); + onChangeTextProp(composeToString(numbers)); + return; + } + + const hasInputs = numbers.filter((n) => ValidationUtils.isNumeric(n)).length !== 0 + + // Fill the array with empty characters if there are no inputs. + if (focusedIndex === 0 && !hasInputs) { + numbers = Array(maxLength).fill(CONST.MAGIC_CODE_EMPTY_CHAR); + + // Deletes the value of the previous input and focuses on it. + } else if (focusedIndex !== 0) { + numbers = [...numbers.slice(0, Math.max(0, focusedIndex - 1)), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex, maxLength)]; + } + + const newFocusedIndex = Math.max(0, focusedIndex - 1); + + // Saves the input string so that it can compare to the change text + // event that will be triggered, this is a workaround for mobile that + // triggers the change text on the event after the key press. + setInputAndIndex(newFocusedIndex); + onChangeTextProp(composeToString(numbers)); + + if (newFocusedIndex !== undefined) { + inputRefs.current?.focus(); + } + } + if (keyValue === 'ArrowLeft' && focusedIndex !== undefined) { + const newFocusedIndex = Math.max(0, focusedIndex - 1); + setInputAndIndex(newFocusedIndex); + inputRefs.current?.focus(); + } else if (keyValue === 'ArrowRight' && focusedIndex !== undefined) { + const newFocusedIndex = Math.min(focusedIndex + 1, maxLength - 1); + setInputAndIndex(newFocusedIndex); + inputRefs.current?.focus(); + } else if (keyValue === 'Enter') { + // We should prevent users from submitting when it's offline. + if (isOffline) { + return; + } + setInput(TEXT_INPUT_EMPTY_STATE); + onFulfill(value); + } + }; + + /** + * If isDisableKeyboard is true we will have to call onKeyPress and onChangeText manually + * as the press on digit pad will not trigger native events. We take lastPressedDigit from props + * as it stores the last pressed digit pressed on digit pad. We take only the first character + * as anything after that is added to differentiate between two same digits passed in a row. + */ + + useEffect(() => { + if (!isDisableKeyboard) { + return; + } + + const val = lastPressedDigit.charAt(0); + onKeyPress({nativeEvent: {key: val}}); + onChangeText(val); + + // We have not added: + // + the onChangeText and onKeyPress as the dependencies because we only want to run this when lastPressedDigit changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastPressedDigit, isDisableKeyboard]); + + return ( + <> + + { + onPress(Math.floor(e.nativeEvent.x / (inputWidth.current / maxLength))); + }} + > + {/* Android does not handle touch on invisible Views so I created a wrapper around invisible TextInput just to handle taps */} + + { + inputWidth.current = e.nativeEvent.layout.width; + }} + ref={(inputRef) => (inputRefs.current = inputRef)} + autoFocus={autoFocus} + inputMode="numeric" + textContentType="oneTimeCode" + name={name} + maxLength={maxLength} + value={input} + hideFocusedState + autoComplete={input.length === 0 && autoComplete} + shouldDelayFocus={input.length === 0 && shouldDelayFocus} + keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + onChangeText={(text: string) => { + onChangeText(text); + }} + onKeyPress={onKeyPress} + onFocus={onFocus} + onBlur={() => { + shouldFocusLast.current = true; + lastFocusedIndex.current = focusedIndex; + setFocusedIndex(undefined); + }} + selectionColor="transparent" + inputStyle={[styles.inputTransparent]} + role={CONST.ROLE.FORM} + style={[styles.inputTransparent]} + textInputContainerStyles={[styles.borderNone]} + /> + + + {getInputPlaceholderSlots(maxLength).map((index) => ( + + + {decomposeString(value, maxLength)[index] || ''} + + + ))} + + {errorText && ( + + )} + + ); +} + +MagicCodeInput.displayName = 'MagicCodeInput'; + +export default forwardRef(MagicCodeInput); From f81a7fa611474f9651b915e2f9452eaca197c164 Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Wed, 3 Jan 2024 12:39:00 +0100 Subject: [PATCH 022/382] fix focus issue --- .../{MagicCodeInput.js => MagicCodeInput.tsx} | 176 ++++++++++-------- 1 file changed, 103 insertions(+), 73 deletions(-) rename src/components/{MagicCodeInput.js => MagicCodeInput.tsx} (72%) diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.tsx similarity index 72% rename from src/components/MagicCodeInput.js rename to src/components/MagicCodeInput.tsx index 55a65237a691..b238c774405c 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.tsx @@ -1,8 +1,7 @@ import PropTypes from 'prop-types'; import React, {forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react'; -import {StyleSheet, View} from 'react-native'; +import {NativeSyntheticEvent, StyleSheet, TextInputFocusEventData, View} from 'react-native'; import {TapGestureHandler} from 'react-native-gesture-handler'; -import _ from 'underscore'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,17 +9,12 @@ import * as Browser from '@libs/Browser'; import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import FormHelpMessage from './FormHelpMessage'; -import networkPropTypes from './networkPropTypes'; -import {withNetwork} from './OnyxProvider'; import Text from './Text'; import TextInput from './TextInput'; const TEXT_INPUT_EMPTY_STATE = ''; const propTypes = { - /** Information about the network */ - network: networkPropTypes.isRequired, - /** Name attribute for the input */ name: PropTypes.string, @@ -63,6 +57,49 @@ const propTypes = { lastPressedDigit: PropTypes.string, }; +type MagicCodeInputProps = { + /** Name attribute for the input */ + name?: string, + + /** Input value */ + value?: string, + + /** Should the input auto focus */ + autoFocus?: boolean, + + /** Whether we should wait before focusing the TextInput, useful when using transitions */ + shouldDelayFocus?: boolean, + + /** Error text to display */ + errorText?: string, + + /** Specifies autocomplete hints for the system, so it can provide autofill */ + autoComplete: 'sms-otp' | 'one-time-code' | 'off', + + /* Should submit when the input is complete */ + shouldSubmitOnComplete?: boolean, + + /** Function to call when the input is changed */ + onChangeText?: (value: string) => void, + + /** Function to call when the input is submitted or fully complete */ + onFulfill?: (value: string) => void, + + /** Specifies if the input has a validation error */ + hasError?: boolean, + + /** Specifies the max length of the input */ + maxLength?: number, + + /** Specifies if the keyboard should be disabled */ + isDisableKeyboard?: boolean, + + /** Last pressed digit on BigDigitPad */ + lastPressedDigit?: string, + + innerRef: unknown; +} + const defaultProps = { value: '', name: '', @@ -82,13 +119,9 @@ const defaultProps = { /** * Converts a given string into an array of numbers that must have the same * number of elements as the number of inputs. - * - * @param {String} value - * @param {Number} length - * @returns {Array} */ -const decomposeString = (value, length) => { - let arr = _.map(value.split('').slice(0, length), (v) => (ValidationUtils.isNumeric(v) ? v : CONST.MAGIC_CODE_EMPTY_CHAR)); +const decomposeString = (value: string, length: number): string[] => { + let arr = value.split('').slice(0, length).map((v) => (ValidationUtils.isNumeric(v) ? v : CONST.MAGIC_CODE_EMPTY_CHAR)) if (arr.length < length) { arr = arr.concat(Array(length - arr.length).fill(CONST.MAGIC_CODE_EMPTY_CHAR)); } @@ -98,26 +131,24 @@ const decomposeString = (value, length) => { /** * Converts an array of strings into a single string. If there are undefined or * empty values, it will replace them with a space. - * - * @param {Array} value - * @returns {String} */ -const composeToString = (value) => _.map(value, (v) => (v === undefined || v === '' ? CONST.MAGIC_CODE_EMPTY_CHAR : v)).join(''); +const composeToString = (value: string[]): string => value.map((v) => (v === undefined || v === '' ? CONST.MAGIC_CODE_EMPTY_CHAR : v)).join(''); -const getInputPlaceholderSlots = (length) => Array.from(Array(length).keys()); +const getInputPlaceholderSlots = (length: number): number[] => Array.from(Array(length).keys()); -function MagicCodeInput(props) { +function MagicCodeInput(props: MagicCodeInputProps) { + const {value = '', name = '', autoFocus = true, shouldDelayFocus = false, errorText = '', shouldSubmitOnComplete = true, onChangeText: onChangeTextProp = () => {}} = props const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const inputRefs = useRef(); const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE); - const [focusedIndex, setFocusedIndex] = useState(0); + const [focusedIndex, setFocusedIndex] = useState(0); const [editIndex, setEditIndex] = useState(0); const [wasSubmitted, setWasSubmitted] = useState(false); const shouldFocusLast = useRef(false); const inputWidth = useRef(0); const lastFocusedIndex = useRef(0); - const lastValue = useRef(TEXT_INPUT_EMPTY_STATE); + const lastValue = useRef(TEXT_INPUT_EMPTY_STATE); useEffect(() => { lastValue.current = input.length; @@ -135,7 +166,7 @@ function MagicCodeInput(props) { inputRefs.current.focus(); }; - const setInputAndIndex = (index) => { + const setInputAndIndex = (index: number) => { setInput(TEXT_INPUT_EMPTY_STATE); setFocusedIndex(index); setEditIndex(index); @@ -156,7 +187,7 @@ function MagicCodeInput(props) { lastFocusedIndex.current = 0; setInputAndIndex(0); inputRefs.current.focus(); - props.onChangeText(''); + onChangeTextProp(''); }, blur() { blurMagicCodeInput(); @@ -164,8 +195,8 @@ function MagicCodeInput(props) { })); const validateAndSubmit = () => { - const numbers = decomposeString(props.value, props.maxLength); - if (wasSubmitted || !props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== props.maxLength || props.network.isOffline) { + const numbers = decomposeString(value, props.maxLength); + if (wasSubmitted || !shouldSubmitOnComplete || numbers.filter((n) => ValidationUtils.isNumeric(n)).length !== props.maxLength || isOffline) { return; } if (!wasSubmitted) { @@ -174,11 +205,11 @@ function MagicCodeInput(props) { // Blurs the input and removes focus from the last input and, if it should submit // on complete, it will call the onFulfill callback. blurMagicCodeInput(); - props.onFulfill(props.value); + props.onFulfill(value); lastValue.current = ''; }; - useNetwork({onReconnect: validateAndSubmit}); + const {isOffline} = useNetwork({onReconnect: validateAndSubmit}); useEffect(() => { validateAndSubmit(); @@ -187,15 +218,15 @@ function MagicCodeInput(props) { // + the editIndex as the dependency because we don't want to run this logic after focusing on an input to edit it after the user has completed the code. // + the props.onFulfill as the dependency because props.onFulfill is changed when the preferred locale changed => avoid auto submit form when preferred locale changed. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.value, props.shouldSubmitOnComplete]); + }, [value, shouldSubmitOnComplete]); /** * Focuses on the input when it is pressed. * - * @param {Object} event - * @param {Number} index + * @param event + * @param index */ - const onFocus = (event) => { + const onFocus = (event: NativeSyntheticEvent) => { if (shouldFocusLast.current) { lastValue.current = TEXT_INPUT_EMPTY_STATE; setInputAndIndex(lastFocusedIndex.current); @@ -207,9 +238,9 @@ function MagicCodeInput(props) { * Callback for the onPress event, updates the indexes * of the currently focused input. * - * @param {Number} index + * @param index */ - const onPress = (index) => { + const onPress = (index: number) => { shouldFocusLast.current = false; // TapGestureHandler works differently on mobile web and native app // On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually @@ -227,20 +258,20 @@ function MagicCodeInput(props) { * It handles both fast typing and only one digit at a time * in a specific position. * - * @param {String} value + * @param textValue */ - const onChangeText = (value) => { - if (_.isUndefined(value) || _.isEmpty(value) || !ValidationUtils.isNumeric(value)) { + const onChangeText = (textValue?: string) => { + if (!textValue?.length || !ValidationUtils.isNumeric(textValue)) { return; } // Checks if one new character was added, or if the content was replaced - const hasToSlice = value.length - 1 === lastValue.current.length && value.slice(0, value.length - 1) === lastValue.current; + const hasToSlice = typeof lastValue.current === 'string' && textValue.length - 1 === lastValue.current.length && textValue.slice(0, textValue.length - 1) === lastValue.current; - // Gets the new value added by the user - const addedValue = hasToSlice ? value.slice(lastValue.current.length, value.length) : value; + // Gets the new textValue added by the user + const addedValue = (hasToSlice && typeof lastValue.current === 'string') ? textValue.slice(lastValue.current.length, textValue.length) : textValue; - lastValue.current = value; + lastValue.current = textValue; // Updates the focused input taking into consideration the last input // edited and the number of digits added by the user. const numbersArr = addedValue @@ -249,13 +280,13 @@ function MagicCodeInput(props) { .slice(0, props.maxLength - editIndex); const updatedFocusedIndex = Math.min(editIndex + (numbersArr.length - 1) + 1, props.maxLength - 1); - let numbers = decomposeString(props.value, props.maxLength); + let numbers = decomposeString(value, props.maxLength); numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, props.maxLength)]; setInputAndIndex(updatedFocusedIndex); const finalInput = composeToString(numbers); - props.onChangeText(finalInput); + onChangeTextProp(finalInput); }; /** @@ -264,11 +295,11 @@ function MagicCodeInput(props) { * NOTE: when using Android Emulator, this can only be tested using * hardware keyboard inputs. * - * @param {Object} event + * @param event */ const onKeyPress = ({nativeEvent: {key: keyValue}}) => { if (keyValue === 'Backspace' || keyValue === '<') { - let numbers = decomposeString(props.value, props.maxLength); + let numbers = decomposeString(value, props.maxLength); // If keyboard is disabled and no input is focused we need to remove // the last entered digit and focus on the correct input @@ -277,59 +308,59 @@ function MagicCodeInput(props) { const indexToFocus = numbers[editIndex] === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex; inputRefs.current[indexToFocus].focus(); - props.onChangeText(props.value.substring(0, indexToFocus)); + onChangeTextProp(value.substring(0, indexToFocus)); return; } // If the currently focused index already has a value, it will delete // that value but maintain the focus on the same input. - if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { + if (focusedIndex && numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { setInput(TEXT_INPUT_EMPTY_STATE); numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, props.maxLength)]; setEditIndex(focusedIndex); - props.onChangeText(composeToString(numbers)); + onChangeTextProp(composeToString(numbers)); return; } - const hasInputs = _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== 0; + const hasInputs = numbers.filter((n) => ValidationUtils.isNumeric(n)).length !== 0; // Fill the array with empty characters if there are no inputs. if (focusedIndex === 0 && !hasInputs) { numbers = Array(props.maxLength).fill(CONST.MAGIC_CODE_EMPTY_CHAR); // Deletes the value of the previous input and focuses on it. - } else if (focusedIndex !== 0) { + } else if (focusedIndex && focusedIndex !== 0) { numbers = [...numbers.slice(0, Math.max(0, focusedIndex - 1)), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex, props.maxLength)]; } - const newFocusedIndex = Math.max(0, focusedIndex - 1); + const newFocusedIndex = Math.max(0, (focusedIndex ?? 0) - 1); // Saves the input string so that it can compare to the change text // event that will be triggered, this is a workaround for mobile that // triggers the change text on the event after the key press. setInputAndIndex(newFocusedIndex); - props.onChangeText(composeToString(numbers)); + onChangeTextProp(composeToString(numbers)); - if (!_.isUndefined(newFocusedIndex)) { + if (newFocusedIndex !== undefined) { inputRefs.current.focus(); } } - if (keyValue === 'ArrowLeft' && !_.isUndefined(focusedIndex)) { + if (keyValue === 'ArrowLeft' && focusedIndex !== undefined) { const newFocusedIndex = Math.max(0, focusedIndex - 1); setInputAndIndex(newFocusedIndex); inputRefs.current.focus(); - } else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) { + } else if (keyValue === 'ArrowRight' && focusedIndex !== undefined) { const newFocusedIndex = Math.min(focusedIndex + 1, props.maxLength - 1); setInputAndIndex(newFocusedIndex); inputRefs.current.focus(); } else if (keyValue === 'Enter') { // We should prevent users from submitting when it's offline. - if (props.network.isOffline) { + if (isOffline) { return; } setInput(TEXT_INPUT_EMPTY_STATE); - props.onFulfill(props.value); + props.onFulfill(value); } }; @@ -345,9 +376,9 @@ function MagicCodeInput(props) { return; } - const value = props.lastPressedDigit.charAt(0); - onKeyPress({nativeEvent: {key: value}}); - onChangeText(value); + const textValue = props.lastPressedDigit.charAt(0); + onKeyPress({nativeEvent: {key: textValue}}); + onChangeText(textValue); // We have not added: // + the onChangeText and onKeyPress as the dependencies because we only want to run this when lastPressedDigit changes. @@ -372,18 +403,18 @@ function MagicCodeInput(props) { inputWidth.current = e.nativeEvent.layout.width; }} ref={(ref) => (inputRefs.current = ref)} - autoFocus={props.autoFocus} + autoFocus={autoFocus} inputMode="numeric" textContentType="oneTimeCode" - name={props.name} + name={name} maxLength={props.maxLength} value={input} hideFocusedState autoComplete={input.length === 0 && props.autoComplete} - shouldDelayFocus={input.length === 0 && props.shouldDelayFocus} + shouldDelayFocus={input.length === 0 && shouldDelayFocus} keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} - onChangeText={(value) => { - onChangeText(value); + onChangeText={(textValue) => { + onChangeText(textValue); }} onKeyPress={onKeyPress} onFocus={onFocus} @@ -394,13 +425,14 @@ function MagicCodeInput(props) { }} selectionColor="transparent" inputStyle={[styles.inputTransparent]} - role={CONST.ACCESSIBILITY_ROLE.TEXT} + // role={CONST.ACCESSIBILITY_ROLE.TEXT} + role='none' style={[styles.inputTransparent]} textInputContainerStyles={[styles.borderNone]} /> - {_.map(getInputPlaceholderSlots(props.maxLength), (index) => ( + {getInputPlaceholderSlots(props.maxLength).map((index) => ( - {decomposeString(props.value, props.maxLength)[index] || ''} + {decomposeString(value, props.maxLength)[index] || ''} ))} - {!_.isEmpty(props.errorText) && ( + {errorText && ( )} @@ -440,6 +472,4 @@ const MagicCodeInputWithRef = forwardRef((props, ref) => ( /> )); -MagicCodeInputWithRef.displayName = 'MagicCodeInputWithRef'; - -export default withNetwork()(MagicCodeInputWithRef); +export default MagicCodeInputWithRef; From 87559169ec7a23ec4748aa2ad06f8743d16a26c2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 14:04:34 +0100 Subject: [PATCH 023/382] add and improve comments --- .../AttachmentCarousel/Pager/index.js | 7 +-- src/components/MultiGestureCanvas/index.js | 21 ++++---- .../MultiGestureCanvas/usePanGesture.js | 52 +++++++++++-------- .../MultiGestureCanvas/usePinchGesture.js | 3 +- .../MultiGestureCanvas/useTapGestures.js | 3 +- src/components/MultiGestureCanvas/utils.ts | 12 ++++- 6 files changed, 56 insertions(+), 42 deletions(-) diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js index 699e2fc812cc..ad844c1df854 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js @@ -47,7 +47,6 @@ const pagerPropTypes = { onPageSelected: PropTypes.func, onTap: PropTypes.func, onSwipe: PropTypes.func, - onSwipeSuccess: PropTypes.func, onSwipeDown: PropTypes.func, onPinchGestureChange: PropTypes.func, forwardedRef: refPropTypes, @@ -58,13 +57,12 @@ const pagerDefaultProps = { onPageSelected: () => {}, onTap: () => {}, onSwipe: noopWorklet, - onSwipeSuccess: () => {}, onSwipeDown: () => {}, onPinchGestureChange: () => {}, forwardedRef: null, }; -function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelected, onTap, onSwipe = noopWorklet, onSwipeSuccess, onSwipeDown, onPinchGestureChange, forwardedRef}) { +function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelected, onTap, onSwipe = noopWorklet, onSwipeDown, onPinchGestureChange, forwardedRef}) { const styles = useThemeStyles(); const shouldPagerScroll = useSharedValue(true); const pagerRef = useRef(null); @@ -124,10 +122,9 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte onPinchGestureChange, onTap, onSwipe, - onSwipeSuccess, onSwipeDown, }), - [isSwipingHorizontally, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeSuccess, onSwipeDown], + [isSwipingHorizontally, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeDown], ); return ( diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index adbc46112621..9da8053aef07 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -1,7 +1,7 @@ import React, {useContext, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import Animated, {cancelAnimation, runOnUI, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import Animated, {cancelAnimation, runOnUI, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring} from 'react-native-reanimated'; import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -14,6 +14,7 @@ import * as MultiGestureCanvasUtils from './utils'; const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; const zoomScaleBounceFactors = MultiGestureCanvasUtils.zoomScaleBounceFactors; +const useWorkletCallback = MultiGestureCanvasUtils.useWorkletCallback; function getDeepDefaultProps({contentSize: contentSizeProp = {}, zoomRange: zoomRangeProp = {}}) { const contentSize = { @@ -37,10 +38,9 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); const pagerRefFallback = useRef(null); - const {onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isSwipingHorizontally, onPinchGestureChange} = attachmentCarouselPagerContext || { + const {onTap, onSwipeDown, pagerRef, shouldPagerScroll, isSwipingHorizontally, onPinchGestureChange} = attachmentCarouselPagerContext || { onTap: () => undefined, - onSwipe: () => undefined, - onSwipeSuccess: () => undefined, + onSwipeDown: () => undefined, onPinchGestureChange: () => undefined, pagerRef: pagerRefFallback, shouldPagerScroll: false, @@ -131,7 +131,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr panTranslateY, isSwipingHorizontally, isSwipingVertically, - onSwipeSuccess, + onSwipeDown, stopAnimation, }); @@ -155,7 +155,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr onPinchGestureChange, }); - // reacts to scale change and enables/disables pager scroll + // Enables/disables the pager scroll based on the zoom scale + // When the content is zoomed in/out, the pager should be disabled useAnimatedReaction( () => zoomScale.value, () => { @@ -179,11 +180,9 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const x = pinchTranslateX.value + pinchBounceTranslateX.value + panTranslateX.value + totalOffsetX.value; const y = pinchTranslateY.value + pinchBounceTranslateY.value + panTranslateY.value + totalOffsetY.value; - // console.log({pinchTranslateY: pinchTranslateY.value, pinchBounceTranslateY: pinchBounceTranslateY.value, panTranslateY: panTranslateY.value, totalOffsetY: totalOffsetY.value}); - - if (isSwipingVertically.value) { - onSwipe(y); - } + // if (isSwipingVertically.value) { + // onSwipe(y); + // } return { transform: [ diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index 5d6279a8be56..a2b92d3bcf3c 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -1,12 +1,13 @@ /* eslint-disable no-param-reassign */ import {Gesture} from 'react-native-gesture-handler'; -import {runOnJS, useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated'; +import {runOnJS, useDerivedValue, useSharedValue, withDecay, withSpring} from 'react-native-reanimated'; import * as MultiGestureCanvasUtils from './utils'; const PAN_DECAY_DECELARATION = 0.9915; -const clamp = MultiGestureCanvasUtils.clamp; const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; +const clamp = MultiGestureCanvasUtils.clamp; +const useWorkletCallback = MultiGestureCanvasUtils.useWorkletCallback; const usePanGesture = ({ canvasSize, @@ -24,22 +25,26 @@ const usePanGesture = ({ panTranslateY, isSwipingVertically, isSwipingHorizontally, - onSwipeSuccess, + onSwipeDown, stopAnimation, }) => { + // The content size after scaling it with the current (total) zoom value const zoomScaledContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]); const zoomScaledContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]); + // Used to track previous touch position for the "swipe down to close" gesture const previousTouch = useSharedValue(null); - // pan velocity to calculate the decay + + // Pan velocity to calculate the decay const panVelocityX = useSharedValue(0); const panVelocityY = useSharedValue(0); - // disable pan vertically when content is smaller than screen + + // Disable "swipe down to close" gesture when content is bigger than the canvas 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 + // 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; @@ -78,12 +83,12 @@ const usePanGesture = ({ const {target, isInBoundaryX, isInBoundaryY, minVector, maxVector} = getBounds(); if (zoomScale.value === zoomRange.min && totalOffsetX.value === 0 && totalOffsetY.value === 0 && panTranslateX.value === 0 && panTranslateY.value === 0) { - // we don't need to run any animations + // We don't need to run any animations return; } + // If we are zoomed out, we want to center the content if (zoomScale.value <= zoomRange.min) { - // just center it totalOffsetX.value = withSpring(0, SPRING_CONFIG); totalOffsetY.value = withSpring(0, SPRING_CONFIG); return; @@ -108,7 +113,7 @@ const usePanGesture = ({ if ( Math.abs(panVelocityY.value) > 0 && zoomScale.value <= zoomRange.max && - // limit vertical pan only when content is smaller than screen + // Limit vertical panning when content is smaller than screen totalOffsetY.value !== minVector.y && totalOffsetY.value !== maxVector.y ) { @@ -143,10 +148,9 @@ const usePanGesture = ({ // if (Math.abs(velocityY) > velocityX && velocityY > 20) { // state.activate(); - // isSwiping.value = true; + // isSwipingVertically.value = true; // previousTouch.value = null; - // runOnJS(onSwipeDown)(); // return; // } // } @@ -163,10 +167,10 @@ const usePanGesture = ({ 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 and move fingers - // since we track it as pinch focal gesture - // we also need to prevent panning when we are swiping horizontally (in the pager) + // Since we're 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. + // We also need to prevent panning when we are swiping horizontally (from page to page) if (evt.numberOfPointers > 1 || isSwipingHorizontally.value) { return; } @@ -184,20 +188,22 @@ const usePanGesture = ({ } }) .onEnd((evt) => { - // add pan translation to total offset + // Add pan translation to total offset totalOffsetX.value += panTranslateX.value; totalOffsetY.value += panTranslateY.value; - // reset pan gesture variables + + // Reset pan gesture variables panTranslateX.value = 0; panTranslateY.value = 0; previousTouch.value = null; - // If we are swiping, we don't want to return to boundaries + // If we are swiping (in the pager), we don't want to return to boundaries if (isSwipingHorizontally.value) { return; } - // swipe to close animation when swiping down + // Triggers the "swipe down to close" animation and the "onSwipeDown" callback, + // which can be used to close the lightbox/carousel if (isSwipingVertically.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); @@ -220,7 +226,7 @@ const usePanGesture = ({ velocity: Math.abs(evt.velocityY) < 1200 ? maybeInvert(1200) : evt.velocityY, }, () => { - runOnJS(onSwipeSuccess)(); + runOnJS(onSwipeDown)(); }, ); return; @@ -229,7 +235,7 @@ const usePanGesture = ({ returnToBoundaries(); - // reset pan gesture variables + // Reset pan gesture variables panVelocityX.value = 0; panVelocityY.value = 0; }) diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 78aed77814cd..fad23d1d409a 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -1,11 +1,12 @@ /* eslint-disable no-param-reassign */ import {useEffect, useState} from 'react'; import {Gesture} from 'react-native-gesture-handler'; -import {runOnJS, useAnimatedReaction, useSharedValue, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import {runOnJS, useAnimatedReaction, useSharedValue, withSpring} from 'react-native-reanimated'; import * as MultiGestureCanvasUtils from './utils'; const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; const zoomScaleBounceFactors = MultiGestureCanvasUtils.zoomScaleBounceFactors; +const useWorkletCallback = MultiGestureCanvasUtils.useWorkletCallback; const usePinchGesture = ({ canvasSize, diff --git a/src/components/MultiGestureCanvas/useTapGestures.js b/src/components/MultiGestureCanvas/useTapGestures.js index a08fbc8fed4c..0b354ed6c54a 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.js +++ b/src/components/MultiGestureCanvas/useTapGestures.js @@ -1,13 +1,14 @@ /* eslint-disable no-param-reassign */ import {useMemo} from 'react'; import {Gesture} from 'react-native-gesture-handler'; -import {runOnJS, useWorkletCallback, withSpring} from 'react-native-reanimated'; +import {runOnJS, withSpring} from 'react-native-reanimated'; import * as MultiGestureCanvasUtils from './utils'; const DOUBLE_TAP_SCALE = 3; const clamp = MultiGestureCanvasUtils.clamp; const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; +const useWorkletCallback = MultiGestureCanvasUtils.useWorkletCallback; const useTapGestures = ({ canvasSize, diff --git a/src/components/MultiGestureCanvas/utils.ts b/src/components/MultiGestureCanvas/utils.ts index da4c1133d237..7a4ba21358c4 100644 --- a/src/components/MultiGestureCanvas/utils.ts +++ b/src/components/MultiGestureCanvas/utils.ts @@ -1,3 +1,5 @@ +import {useCallback} from 'react'; + const SPRING_CONFIG = { mass: 1, stiffness: 1000, @@ -8,10 +10,18 @@ const zoomScaleBounceFactors = { min: 0.7, max: 1.5, }; + function clamp(value: number, lowerBound: number, upperBound: number) { 'worklet'; return Math.min(Math.max(lowerBound, value), upperBound); } -export {clamp, SPRING_CONFIG, zoomScaleBounceFactors}; +const useWorkletCallback = (callback: Parameters[0], deps: Parameters[1] = []) => { + 'worklet'; + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useCallback(callback, deps); +}; + +export {SPRING_CONFIG, zoomScaleBounceFactors, clamp, useWorkletCallback}; From 0e7fe03e1a61782d40e8ce08b3144c0bf06cbb84 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 14:17:40 +0100 Subject: [PATCH 024/382] remove "swipe down to close gesture" --- .../AttachmentCarousel/Pager/index.js | 16 +--- src/components/MultiGestureCanvas/index.js | 10 +-- .../MultiGestureCanvas/usePanGesture.js | 75 ++----------------- 3 files changed, 9 insertions(+), 92 deletions(-) diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js index ad844c1df854..a85ae10e2328 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js @@ -29,12 +29,6 @@ function usePageScrollHandler(handlers, dependencies) { ); } -const noopWorklet = () => { - 'worklet'; - - // noop -}; - const pagerPropTypes = { items: PropTypes.arrayOf( PropTypes.shape({ @@ -46,8 +40,6 @@ const pagerPropTypes = { initialIndex: PropTypes.number, onPageSelected: PropTypes.func, onTap: PropTypes.func, - onSwipe: PropTypes.func, - onSwipeDown: PropTypes.func, onPinchGestureChange: PropTypes.func, forwardedRef: refPropTypes, }; @@ -56,13 +48,11 @@ const pagerDefaultProps = { initialIndex: 0, onPageSelected: () => {}, onTap: () => {}, - onSwipe: noopWorklet, - onSwipeDown: () => {}, onPinchGestureChange: () => {}, forwardedRef: null, }; -function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelected, onTap, onSwipe = noopWorklet, onSwipeDown, onPinchGestureChange, forwardedRef}) { +function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelected, onTap, onPinchGestureChange, forwardedRef}) { const styles = useThemeStyles(); const shouldPagerScroll = useSharedValue(true); const pagerRef = useRef(null); @@ -121,10 +111,8 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte shouldPagerScroll, onPinchGestureChange, onTap, - onSwipe, - onSwipeDown, }), - [isSwipingHorizontally, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeDown], + [isSwipingHorizontally, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap], ); return ( diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index 9da8053aef07..1d91e4cad20e 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -38,9 +38,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); const pagerRefFallback = useRef(null); - const {onTap, onSwipeDown, pagerRef, shouldPagerScroll, isSwipingHorizontally, onPinchGestureChange} = attachmentCarouselPagerContext || { + const {onTap, pagerRef, shouldPagerScroll, isSwipingHorizontally, onPinchGestureChange} = attachmentCarouselPagerContext || { onTap: () => undefined, - onSwipeDown: () => undefined, onPinchGestureChange: () => undefined, pagerRef: pagerRefFallback, shouldPagerScroll: false, @@ -63,7 +62,6 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr // pan gesture const panTranslateX = useSharedValue(0); const panTranslateY = useSharedValue(0); - const isSwipingVertically = useSharedValue(false); const panGestureRef = useRef(Gesture.Pan()); // pinch gesture @@ -130,8 +128,6 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr panTranslateX, panTranslateY, isSwipingHorizontally, - isSwipingVertically, - onSwipeDown, stopAnimation, }); @@ -180,10 +176,6 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const x = pinchTranslateX.value + pinchBounceTranslateX.value + panTranslateX.value + totalOffsetX.value; const y = pinchTranslateY.value + pinchBounceTranslateY.value + panTranslateY.value + totalOffsetY.value; - // if (isSwipingVertically.value) { - // onSwipe(y); - // } - return { transform: [ { diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index a2b92d3bcf3c..807252670b43 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ import {Gesture} from 'react-native-gesture-handler'; -import {runOnJS, useDerivedValue, useSharedValue, withDecay, withSpring} from 'react-native-reanimated'; +import {useDerivedValue, useSharedValue, withDecay, withSpring} from 'react-native-reanimated'; import * as MultiGestureCanvasUtils from './utils'; const PAN_DECAY_DECELARATION = 0.9915; @@ -23,9 +23,7 @@ const usePanGesture = ({ totalOffsetY, panTranslateX, panTranslateY, - isSwipingVertically, isSwipingHorizontally, - onSwipeDown, stopAnimation, }) => { // The content size after scaling it with the current (total) zoom value @@ -39,9 +37,6 @@ const usePanGesture = ({ const panVelocityX = useSharedValue(0); const panVelocityY = useSharedValue(0); - // Disable "swipe down to close" gesture when content is bigger than the canvas - 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 @@ -107,9 +102,7 @@ const usePanGesture = ({ totalOffsetX.value = withSpring(target.x, SPRING_CONFIG); } - if (!canPanVertically.value) { - totalOffsetY.value = withSpring(target.y, SPRING_CONFIG); - } else if (isInBoundaryY) { + if (isInBoundaryY) { if ( Math.abs(panVelocityY.value) > 0 && zoomScale.value <= zoomRange.max && @@ -124,9 +117,7 @@ const usePanGesture = ({ }); } } else { - totalOffsetY.value = withSpring(target.y, SPRING_CONFIG, () => { - isSwipingVertically.value = false; - }); + totalOffsetY.value = withSpring(target.y, SPRING_CONFIG); } }); @@ -138,23 +129,6 @@ const usePanGesture = ({ state.activate(); } - // TODO: Swipe down to close carousel gesture - // this needs fine tuning to work properly - // if (!isSwipingHorizontally.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(); - - // isSwipingVertically.value = true; - // previousTouch.value = null; - - // return; - // } - // } - if (previousTouch.value == null) { previousTouch.value = { x: evt.allTouches[0].x, @@ -176,18 +150,12 @@ const usePanGesture = ({ } panVelocityX.value = evt.velocityX; - panVelocityY.value = evt.velocityY; - if (!isSwipingVertically.value) { - panTranslateX.value += evt.changeX; - } - - if (canPanVertically.value || isSwipingVertically.value) { - panTranslateY.value += evt.changeY; - } + panTranslateX.value += evt.changeX; + panTranslateY.value += evt.changeY; }) - .onEnd((evt) => { + .onEnd(() => { // Add pan translation to total offset totalOffsetX.value += panTranslateX.value; totalOffsetY.value += panTranslateY.value; @@ -202,37 +170,6 @@ const usePanGesture = ({ return; } - // Triggers the "swipe down to close" animation and the "onSwipeDown" callback, - // which can be used to close the lightbox/carousel - if (isSwipingVertically.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; - }; - - totalOffsetY.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(onSwipeDown)(); - }, - ); - return; - } - } - returnToBoundaries(); // Reset pan gesture variables From 70bd5b786ccf0f81205b1a95176f64dc55d7050a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 14:31:39 +0100 Subject: [PATCH 025/382] remove unused props --- src/components/AttachmentModal.js | 1 - src/components/Attachments/AttachmentCarousel/index.native.js | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 863e59aa4474..7c062366f8a7 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -449,7 +449,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/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js index f5479b73abdb..003c27844fbc 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.js +++ b/src/components/Attachments/AttachmentCarousel/index.native.js @@ -18,7 +18,7 @@ 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(); @@ -147,7 +147,6 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, setShouldShowArrows(true); } }} - onSwipeDown={onClose} ref={pagerRef} /> From f95f9c664917d60af6e9b234373a70e95eb179c5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 14:34:33 +0100 Subject: [PATCH 026/382] rename variable --- .../Attachments/AttachmentCarousel/Pager/index.js | 10 +++++----- src/components/MultiGestureCanvas/index.js | 8 ++++---- src/components/MultiGestureCanvas/usePanGesture.js | 6 +++--- src/components/MultiGestureCanvas/usePinchGesture.js | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js index a85ae10e2328..d7d6bda1be29 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js @@ -57,7 +57,7 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte const shouldPagerScroll = useSharedValue(true); const pagerRef = useRef(null); - const isSwipingHorizontally = useSharedValue(false); + const isSwipingInPager = useSharedValue(false); const activeIndex = useSharedValue(initialIndex); const pageScrollHandler = usePageScrollHandler( @@ -66,7 +66,7 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte 'worklet'; activeIndex.value = e.position; - isSwipingHorizontally.value = e.offset !== 0; + isSwipingInPager.value = e.offset !== 0; }, }, [], @@ -82,7 +82,7 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte // we use reanimated for this since onPageSelected is called // in the middle of the pager animation useAnimatedReaction( - () => isSwipingHorizontally.value, + () => isSwipingInPager.value, (stillScrolling) => { if (stillScrolling) { return; @@ -106,13 +106,13 @@ function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelecte const contextValue = useMemo( () => ({ - isSwipingHorizontally, + isSwipingInPager, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, }), - [isSwipingHorizontally, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap], + [isSwipingInPager, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap], ); return ( diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index 1d91e4cad20e..24f01cf5f3f1 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -38,12 +38,12 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); const pagerRefFallback = useRef(null); - const {onTap, pagerRef, shouldPagerScroll, isSwipingHorizontally, onPinchGestureChange} = attachmentCarouselPagerContext || { + const {onTap, pagerRef, shouldPagerScroll, isSwipingInPager, onPinchGestureChange} = attachmentCarouselPagerContext || { onTap: () => undefined, onPinchGestureChange: () => undefined, pagerRef: pagerRefFallback, shouldPagerScroll: false, - isSwipingHorizontally: false, + isSwipingInPager: false, ...props, }; @@ -127,7 +127,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr totalOffsetY, panTranslateX, panTranslateY, - isSwipingHorizontally, + isSwipingInPager, stopAnimation, }); @@ -145,7 +145,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr pinchBounceTranslateX, pinchBounceTranslateY, pinchScaleOffset, - isSwipingHorizontally, + isSwipingInPager, stopAnimation, onScaleChanged, onPinchGestureChange, diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index 807252670b43..b6639dcac1a6 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -23,7 +23,7 @@ const usePanGesture = ({ totalOffsetY, panTranslateX, panTranslateY, - isSwipingHorizontally, + isSwipingInPager, stopAnimation, }) => { // The content size after scaling it with the current (total) zoom value @@ -145,7 +145,7 @@ const usePanGesture = ({ // we need to make sure that we don't pan when we pinch AND move fingers // since we track it as pinch focal gesture. // We also need to prevent panning when we are swiping horizontally (from page to page) - if (evt.numberOfPointers > 1 || isSwipingHorizontally.value) { + if (evt.numberOfPointers > 1 || isSwipingInPager.value) { return; } @@ -166,7 +166,7 @@ const usePanGesture = ({ previousTouch.value = null; // If we are swiping (in the pager), we don't want to return to boundaries - if (isSwipingHorizontally.value) { + if (isSwipingInPager.value) { return; } diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index fad23d1d409a..630ace4e3042 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -22,7 +22,7 @@ const usePinchGesture = ({ pinchBounceTranslateX, pinchBounceTranslateY, pinchScaleOffset, - isSwipingHorizontally, + isSwipingInPager, stopAnimation, onScaleChanged, onPinchGestureChange, @@ -46,7 +46,7 @@ const usePinchGesture = ({ const pinchGesture = Gesture.Pinch() .onTouchesDown((evt, state) => { // we don't want to activate pinch gesture when we are scrolling pager - if (!isSwipingHorizontally.value) { + if (!isSwipingInPager.value) { return; } From 610f59fa1713148cb971ddcb913b9b782bd23cd0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 14:56:43 +0100 Subject: [PATCH 027/382] simplify pinch gesture --- src/components/MultiGestureCanvas/index.js | 56 ++++++------- .../MultiGestureCanvas/usePinchGesture.js | 78 ++++++++++++------- .../MultiGestureCanvas/useTapGestures.js | 6 +- 3 files changed, 80 insertions(+), 60 deletions(-) diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index 24f01cf5f3f1..ade0a7b54c38 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -50,27 +50,24 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const {minScale: minContentScale, maxScale: maxContentScale} = useMemo(() => getCanvasFitScale({canvasSize, contentSize}), [canvasSize, contentSize]); 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 + + // 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]); - // total offset of the canvas (panning + pinching offset) - const totalOffsetX = useSharedValue(0); - const totalOffsetY = useSharedValue(0); - - // pan gesture const panTranslateX = useSharedValue(0); const panTranslateY = useSharedValue(0); const panGestureRef = useRef(Gesture.Pan()); - // pinch gesture + const pinchScale = useSharedValue(1); const pinchTranslateX = useSharedValue(0); const pinchTranslateY = useSharedValue(0); - const pinchBounceTranslateX = useSharedValue(0); - const pinchBounceTranslateY = useSharedValue(0); - // scale in between gestures - const pinchScaleOffset = useSharedValue(1); + + // Total offset of the canvas + // Contains both offsets from panning and pinching gestures + const totalOffsetX = useSharedValue(0); + const totalOffsetY = useSharedValue(0); const stopAnimation = useWorkletCallback(() => { cancelAnimation(totalOffsetX); @@ -78,23 +75,30 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr }); const reset = useWorkletCallback((animated) => { - pinchScaleOffset.value = 1; + pinchScale.value = 1; stopAnimation(); + pinchScale.value = 1; + if (animated) { totalOffsetX.value = withSpring(0, SPRING_CONFIG); totalOffsetY.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); - } else { - totalOffsetX.value = 0; - totalOffsetY.value = 0; - zoomScale.value = 1; - panTranslateX.value = 0; - panTranslateY.value = 0; - pinchTranslateX.value = 0; - pinchTranslateY.value = 0; + return; } + + totalOffsetX.value = 0; + totalOffsetY.value = 0; + panTranslateX.value = 0; + panTranslateY.value = 0; + pinchTranslateX.value = 0; + pinchTranslateY.value = 0; + zoomScale.value = 1; }); const {singleTap, doubleTap} = useTapGestures({ @@ -105,7 +109,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr panGestureRef, totalOffsetX, totalOffsetY, - pinchScaleOffset, + pinchScale, zoomScale, reset, stopAnimation, @@ -142,9 +146,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr totalOffsetY, pinchTranslateX, pinchTranslateY, - pinchBounceTranslateX, - pinchBounceTranslateY, - pinchScaleOffset, + pinchScale, isSwipingInPager, stopAnimation, onScaleChanged, @@ -173,8 +175,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr }, [isActive, mounted, reset]); const animatedStyles = useAnimatedStyle(() => { - const x = pinchTranslateX.value + pinchBounceTranslateX.value + panTranslateX.value + totalOffsetX.value; - const y = pinchTranslateY.value + pinchBounceTranslateY.value + panTranslateY.value + totalOffsetY.value; + const x = pinchTranslateX.value + panTranslateX.value + totalOffsetX.value; + const y = pinchTranslateY.value + panTranslateY.value + totalOffsetY.value; return { transform: [ diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 630ace4e3042..47964aa7bb2e 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -17,25 +17,32 @@ const usePinchGesture = ({ zoomRange, totalOffsetX, totalOffsetY, - pinchTranslateX, - pinchTranslateY, - pinchBounceTranslateX, - pinchBounceTranslateY, - pinchScaleOffset, + totalPinchTranslateX, + totalPinchTranslateY, + pinchScale, isSwipingInPager, stopAnimation, onScaleChanged, onPinchGestureChange, }) => { const isPinchGestureRunning = useSharedValue(false); - // used to store event scale value when we limit scale - const pinchGestureScale = useSharedValue(1); - // origin of the pinch gesture + + // Used to store event scale value when we limit scale + const currentPinchScale = useSharedValue(1); + + // Origin of the pinch gesture const pinchOrigin = { x: useSharedValue(0), y: useSharedValue(0), }; + const pinchTranslateX = useSharedValue(0); + const pinchTranslateY = useSharedValue(0); + // In order to keep track of the "bounce" effect when pinching over/under the min/max zoom scale + // we need to have extra "bounce" translation variables + const pinchBounceTranslateX = useSharedValue(0); + const pinchBounceTranslateY = useSharedValue(0); + const getAdjustedFocal = useWorkletCallback( (focalX, focalY) => ({ x: focalX - (canvasSize.width / 2 + totalOffsetX.value), @@ -45,7 +52,7 @@ const usePinchGesture = ({ ); const pinchGesture = Gesture.Pinch() .onTouchesDown((evt, state) => { - // we don't want to activate pinch gesture when we are scrolling pager + // We don't want to activate pinch gesture when we are scrolling pager if (!isSwipingInPager.value) { return; } @@ -64,60 +71,70 @@ const usePinchGesture = ({ pinchOrigin.y.value = adjustedFocal.y; }) .onChange((evt) => { - const newZoomScale = pinchScaleOffset.value * evt.scale; + const newZoomScale = pinchScale.value * evt.scale; - // limit zoom scale to zoom range and bounce if we go out of range + // Limit zoom scale to zoom range including bounce range if (zoomScale.value >= zoomRange.min * zoomScaleBounceFactors.min && zoomScale.value <= zoomRange.max * zoomScaleBounceFactors.max) { zoomScale.value = newZoomScale; - pinchGestureScale.value = evt.scale; + currentPinchScale.value = evt.scale; } - // calculate new pinch translation + // Calculate new pinch translation const adjustedFocal = getAdjustedFocal(evt.focalX, evt.focalY); - const newPinchTranslateX = adjustedFocal.x + pinchGestureScale.value * pinchOrigin.x.value * -1; - const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * pinchOrigin.y.value * -1; + const newPinchTranslateX = adjustedFocal.x + currentPinchScale.value * pinchOrigin.x.value * -1; + const newPinchTranslateY = adjustedFocal.y + currentPinchScale.value * pinchOrigin.y.value * -1; 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 to separate variables - // so that we can revert the bounce once pinch gesture is released + // 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; } + + totalPinchTranslateX.value = pinchTranslateX.value + pinchBounceTranslateX.value; + totalPinchTranslateY.value = pinchTranslateY.value + pinchBounceTranslateY.value; }) .onEnd(() => { // Add pinch translation to total offset - totalOffsetX.value += pinchTranslateX.value; - totalOffsetY.value += pinchTranslateY.value; + totalOffsetX.value += totalPinchTranslateX.value; + totalOffsetY.value += totalPinchTranslateX.value; + // Reset pinch gesture variables pinchTranslateX.value = 0; pinchTranslateY.value = 0; - pinchGestureScale.value = 1; + totalPinchTranslateX.value = 0; + totalPinchTranslateY.value = 0; + currentPinchScale.value = 1; isPinchGestureRunning.value = false; + // 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) { - pinchScaleOffset.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); } else if (zoomScale.value > zoomRange.max) { - pinchScaleOffset.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); } else { - pinchScaleOffset.value = zoomScale.value; - } - - if (pinchBounceTranslateX.value !== 0 || pinchBounceTranslateY.value !== 0) { - pinchBounceTranslateX.value = withSpring(0, SPRING_CONFIG); - pinchBounceTranslateY.value = withSpring(0, SPRING_CONFIG); + // Otherwise, we just update the pinch scale offset + pinchScale.value = zoomScale.value; } if (onScaleChanged != null) { - runOnJS(onScaleChanged)(pinchScaleOffset.value); + runOnJS(onScaleChanged)(pinchScale.value); } }); - // Triggers "onPinchGestureChange" callback when pinch scale changes + // The "useAnimatedReaction" triggers a state update to run the "onPinchGestureChange" only once per re-render const [isPinchGestureInUse, setIsPinchGestureInUse] = useState(false); useAnimatedReaction( () => [zoomScale.value, isPinchGestureRunning.value], @@ -128,6 +145,7 @@ const usePinchGesture = ({ } }, ); + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => onPinchGestureChange(isPinchGestureInUse), [isPinchGestureInUse]); diff --git a/src/components/MultiGestureCanvas/useTapGestures.js b/src/components/MultiGestureCanvas/useTapGestures.js index 0b354ed6c54a..dbba208801e7 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.js +++ b/src/components/MultiGestureCanvas/useTapGestures.js @@ -18,7 +18,7 @@ const useTapGestures = ({ panGestureRef, totalOffsetX, totalOffsetY, - pinchScaleOffset, + pinchScale, zoomScale, reset, stopAnimation, @@ -28,7 +28,7 @@ const useTapGestures = ({ 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 + // On double tap zoom to fill, but at least zoom by 3x const doubleTapScale = useMemo(() => Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale), [maxContentScale, minContentScale]); const zoomToCoordinates = useWorkletCallback( @@ -87,7 +87,7 @@ const useTapGestures = ({ totalOffsetX.value = withSpring(target.x, SPRING_CONFIG); totalOffsetY.value = withSpring(target.y, SPRING_CONFIG); zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG); - pinchScaleOffset.value = doubleTapScale; + pinchScale.value = doubleTapScale; }, [scaledWidth, scaledHeight, canvasSize, doubleTapScale], ); From 730a9981d6494598e6c3bc50973f38926cdf8e14 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 15:19:36 +0100 Subject: [PATCH 028/382] fix: variable names --- src/components/MultiGestureCanvas/usePinchGesture.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 47964aa7bb2e..01737ec0efb0 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -17,8 +17,8 @@ const usePinchGesture = ({ zoomRange, totalOffsetX, totalOffsetY, - totalPinchTranslateX, - totalPinchTranslateY, + pinchTranslateX: totalPinchTranslateX, + pinchTranslateY: totalPinchTranslateY, pinchScale, isSwipingInPager, stopAnimation, From 0b205545bf84910dc8538e0c759643d684f61996 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 15:34:14 +0100 Subject: [PATCH 029/382] fix: calculation of total pinch translation --- .../MultiGestureCanvas/usePinchGesture.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 01737ec0efb0..3d37f16e789e 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -43,6 +43,14 @@ const usePinchGesture = ({ const pinchBounceTranslateX = useSharedValue(0); const pinchBounceTranslateY = useSharedValue(0); + useAnimatedReaction( + () => [pinchTranslateX.value, pinchTranslateY.value, pinchBounceTranslateX.value, pinchBounceTranslateY.value], + ([translateX, translateY, bounceX, bounceY]) => { + totalPinchTranslateX.value = translateX + bounceX; + totalPinchTranslateY.value = translateY + bounceY; + }, + ); + const getAdjustedFocal = useWorkletCallback( (focalX, focalY) => ({ x: focalX - (canvasSize.width / 2 + totalOffsetX.value), @@ -93,9 +101,6 @@ const usePinchGesture = ({ pinchBounceTranslateX.value = newPinchTranslateX - pinchTranslateX.value; pinchBounceTranslateY.value = newPinchTranslateY - pinchTranslateY.value; } - - totalPinchTranslateX.value = pinchTranslateX.value + pinchBounceTranslateX.value; - totalPinchTranslateY.value = pinchTranslateY.value + pinchBounceTranslateY.value; }) .onEnd(() => { // Add pinch translation to total offset @@ -105,8 +110,6 @@ const usePinchGesture = ({ // Reset pinch gesture variables pinchTranslateX.value = 0; pinchTranslateY.value = 0; - totalPinchTranslateX.value = 0; - totalPinchTranslateY.value = 0; currentPinchScale.value = 1; isPinchGestureRunning.value = false; From 9483b8f79878524bca452ec168ec92a4622f86a6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 16:21:46 +0100 Subject: [PATCH 030/382] rename variables --- src/components/MultiGestureCanvas/index.js | 47 +++++++++---------- .../MultiGestureCanvas/usePanGesture.js | 6 +-- .../MultiGestureCanvas/usePinchGesture.js | 18 +++---- .../MultiGestureCanvas/useTapGestures.js | 20 ++------ 4 files changed, 38 insertions(+), 53 deletions(-) diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index ade0a7b54c38..a1969cb5a904 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -64,14 +64,13 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const pinchTranslateX = useSharedValue(0); const pinchTranslateY = useSharedValue(0); - // Total offset of the canvas - // Contains both offsets from panning and pinching gestures - const totalOffsetX = useSharedValue(0); - const totalOffsetY = useSharedValue(0); + // Total offset of the content including previous translations from panning and pinching gestures + const offsetX = useSharedValue(0); + const offsetY = useSharedValue(0); const stopAnimation = useWorkletCallback(() => { - cancelAnimation(totalOffsetX); - cancelAnimation(totalOffsetY); + cancelAnimation(offsetX); + cancelAnimation(offsetY); }); const reset = useWorkletCallback((animated) => { @@ -82,8 +81,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr pinchScale.value = 1; if (animated) { - totalOffsetX.value = withSpring(0, SPRING_CONFIG); - totalOffsetY.value = withSpring(0, SPRING_CONFIG); + 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); @@ -92,8 +91,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr return; } - totalOffsetX.value = 0; - totalOffsetY.value = 0; + offsetX.value = 0; + offsetY.value = 0; panTranslateX.value = 0; panTranslateY.value = 0; pinchTranslateX.value = 0; @@ -101,14 +100,14 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr zoomScale.value = 1; }); - const {singleTap, doubleTap} = useTapGestures({ + const {singleTapGesture, doubleTapGesture} = useTapGestures({ canvasSize, contentSize, minContentScale, maxContentScale, panGestureRef, - totalOffsetX, - totalOffsetY, + offsetX, + offsetY, pinchScale, zoomScale, reset, @@ -120,15 +119,15 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const panGesture = usePanGesture({ canvasSize, contentSize, + singleTapGesture, + doubleTapGesture, panGestureRef, pagerRef, - singleTap, - doubleTap, zoomScale, zoomRange, totalScale, - totalOffsetX, - totalOffsetY, + offsetX, + offsetY, panTranslateX, panTranslateY, isSwipingInPager, @@ -137,13 +136,13 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const pinchGesture = usePinchGesture({ canvasSize, - singleTap, - doubleTap, + singleTapGesture, + doubleTapGesture, panGesture, zoomScale, zoomRange, - totalOffsetX, - totalOffsetY, + offsetX, + offsetY, pinchTranslateX, pinchTranslateY, pinchScale, @@ -175,8 +174,8 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr }, [isActive, mounted, reset]); const animatedStyles = useAnimatedStyle(() => { - const x = pinchTranslateX.value + panTranslateX.value + totalOffsetX.value; - const y = pinchTranslateY.value + panTranslateY.value + totalOffsetY.value; + const x = pinchTranslateX.value + panTranslateX.value + offsetX.value; + const y = pinchTranslateY.value + panTranslateY.value + offsetY.value; return { transform: [ @@ -202,7 +201,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr }, ]} > - + { stopAnimation(); }) diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 3d37f16e789e..1756365cec88 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -10,13 +10,13 @@ const useWorkletCallback = MultiGestureCanvasUtils.useWorkletCallback; const usePinchGesture = ({ canvasSize, - singleTap, - doubleTap, + singleTapGesture, + doubleTapGesture, panGesture, zoomScale, zoomRange, - totalOffsetX, - totalOffsetY, + offsetX, + offsetY, pinchTranslateX: totalPinchTranslateX, pinchTranslateY: totalPinchTranslateY, pinchScale, @@ -53,8 +53,8 @@ const usePinchGesture = ({ const getAdjustedFocal = useWorkletCallback( (focalX, focalY) => ({ - x: focalX - (canvasSize.width / 2 + totalOffsetX.value), - y: focalY - (canvasSize.height / 2 + totalOffsetY.value), + x: focalX - (canvasSize.width / 2 + offsetX.value), + y: focalY - (canvasSize.height / 2 + offsetY.value), }), [canvasSize.width, canvasSize.height], ); @@ -67,7 +67,7 @@ const usePinchGesture = ({ state.fail(); }) - .simultaneousWithExternalGesture(panGesture, singleTap, doubleTap) + .simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture) .onStart((evt) => { isPinchGestureRunning.value = true; @@ -104,8 +104,8 @@ const usePinchGesture = ({ }) .onEnd(() => { // Add pinch translation to total offset - totalOffsetX.value += totalPinchTranslateX.value; - totalOffsetY.value += totalPinchTranslateX.value; + offsetX.value += totalPinchTranslateX.value; + offsetY.value += totalPinchTranslateX.value; // Reset pinch gesture variables pinchTranslateX.value = 0; diff --git a/src/components/MultiGestureCanvas/useTapGestures.js b/src/components/MultiGestureCanvas/useTapGestures.js index dbba208801e7..c2df4392f96c 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.js +++ b/src/components/MultiGestureCanvas/useTapGestures.js @@ -10,21 +10,7 @@ const clamp = MultiGestureCanvasUtils.clamp; const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; const useWorkletCallback = MultiGestureCanvasUtils.useWorkletCallback; -const useTapGestures = ({ - canvasSize, - contentSize, - minContentScale, - maxContentScale, - panGestureRef, - totalOffsetX, - totalOffsetY, - pinchScale, - zoomScale, - reset, - stopAnimation, - onScaleChanged, - onTap, -}) => { +const useTapGestures = ({canvasSize, contentSize, minContentScale, maxContentScale, panGestureRef, offsetX, offsetY, pinchScale, zoomScale, reset, stopAnimation, onScaleChanged, onTap}) => { const scaledWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]); const scaledHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]); @@ -84,8 +70,8 @@ const useTapGestures = ({ target.y = 0; } - totalOffsetX.value = withSpring(target.x, SPRING_CONFIG); - totalOffsetY.value = withSpring(target.y, SPRING_CONFIG); + offsetX.value = withSpring(target.x, SPRING_CONFIG); + offsetY.value = withSpring(target.y, SPRING_CONFIG); zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG); pinchScale.value = doubleTapScale; }, From 14f8bb545566726b8ad19efe5a5acf037a75eddc Mon Sep 17 00:00:00 2001 From: Pedro Guerreiro Date: Wed, 3 Jan 2024 16:00:18 +0000 Subject: [PATCH 031/382] chore: apply pull request feedback --- src/libs/Navigation/Navigation.ts | 22 +++++++++---------- src/pages/ValidateLoginPage/index.website.tsx | 7 +++++- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 2c250b6b89b2..23277fe30636 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -1,18 +1,18 @@ -import { findFocusedRoute, getActionFromState } from '@react-navigation/core'; -import { CommonActions, EventArg, getPathFromState, NavigationContainerEventMap, NavigationState, PartialState, StackActions } from '@react-navigation/native'; +import {findFocusedRoute, getActionFromState} from '@react-navigation/core'; +import {CommonActions, EventArg, getPathFromState, NavigationContainerEventMap, NavigationState, PartialState, StackActions} from '@react-navigation/native'; import findLastIndex from 'lodash/findLastIndex'; import Log from '@libs/Log'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; -import ROUTES, { Route } from '@src/ROUTES'; -import SCREENS, { PROTECTED_SCREENS } from '@src/SCREENS'; +import ROUTES, {Route} from '@src/ROUTES'; +import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS'; import getStateFromPath from './getStateFromPath'; import originalGetTopmostReportActionId from './getTopmostReportActionID'; import originalGetTopmostReportId from './getTopmostReportId'; import linkingConfig from './linkingConfig'; import linkTo from './linkTo'; import navigationRef from './navigationRef'; -import { StackNavigationAction, StateOrRoute } from './types'; +import {StackNavigationAction, StateOrRoute} from './types'; let resolveNavigationIsReadyPromise: () => void; const navigationIsReadyPromise = new Promise((resolve) => { @@ -82,7 +82,7 @@ function getDistanceFromPathInRootNavigator(path: string): number { return index; } - currentState = { ...currentState, routes: currentState.routes.slice(0, -1), index: currentState.index - 1 }; + currentState = {...currentState, routes: currentState.routes.slice(0, -1), index: currentState.index - 1}; } return -1; @@ -123,7 +123,7 @@ function isActiveRoute(routePath: Route): boolean { * @param [type] - Type of action to perform. Currently UP is supported. */ function navigate(route: Route = ROUTES.HOME, type?: string) { - if (!canNavigate('navigate', { route })) { + if (!canNavigate('navigate', {route})) { // Store intended route if the navigator is not yet available, // we will try again after the NavigationContainer is ready Log.hmmm(`[Navigation] Container not yet ready, storing route as pending: ${route}`); @@ -228,9 +228,9 @@ function dismissModal(targetReportID?: string) { } else if (targetReportID && rootState.routes.some((route) => route.name === SCREENS.NOT_FOUND)) { const lastRouteIndex = rootState.routes.length - 1; const centralRouteIndex = findLastIndex(rootState.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR); - navigationRef.current?.dispatch({ ...StackActions.pop(lastRouteIndex - centralRouteIndex), target: rootState.key }); + navigationRef.current?.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: rootState.key}); } else { - navigationRef.current?.dispatch({ ...StackActions.pop(), target: rootState.key }); + navigationRef.current?.dispatch({...StackActions.pop(), target: rootState.key}); } break; default: { @@ -315,7 +315,7 @@ function waitForProtectedRoutes() { return; } - const unsubscribe = navigationRef.current?.addListener('state', ({ data }) => { + const unsubscribe = navigationRef.current?.addListener('state', ({data}) => { const state = data?.state; if (navContainsProtectedRoutes(state)) { unsubscribe?.(); @@ -343,4 +343,4 @@ export default { waitForProtectedRoutes, }; -export { navigationRef }; +export {navigationRef}; diff --git a/src/pages/ValidateLoginPage/index.website.tsx b/src/pages/ValidateLoginPage/index.website.tsx index 1a68405934bc..12e680172198 100644 --- a/src/pages/ValidateLoginPage/index.website.tsx +++ b/src/pages/ValidateLoginPage/index.website.tsx @@ -14,8 +14,13 @@ import SCREENS from '@src/SCREENS'; import type {Account, Credentials, Session as SessionType} from '@src/types/onyx'; type ValidateLoginPageOnyxProps = { + /** The details about the account that the user is signing in with */ account: OnyxEntry; + + /** The credentials of the person logging in */ credentials: OnyxEntry; + + /** Session of currently logged in user */ session: OnyxEntry; }; @@ -24,7 +29,7 @@ type ValidateLoginPageProps = ValidateLoginPageOnyxProps & StackScreenProps Date: Wed, 3 Jan 2024 17:22:02 +0100 Subject: [PATCH 032/382] improve comments and remove aliases --- src/components/MultiGestureCanvas/index.js | 25 +++-- .../MultiGestureCanvas/usePanGesture.js | 42 ++++---- .../MultiGestureCanvas/usePinchGesture.js | 19 ++-- .../MultiGestureCanvas/useTapGestures.js | 95 +++++++++++-------- 4 files changed, 92 insertions(+), 89 deletions(-) diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index a1969cb5a904..0964f63913fd 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -12,10 +12,6 @@ import usePinchGesture from './usePinchGesture'; import useTapGestures from './useTapGestures'; import * as MultiGestureCanvasUtils from './utils'; -const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; -const zoomScaleBounceFactors = MultiGestureCanvasUtils.zoomScaleBounceFactors; -const useWorkletCallback = MultiGestureCanvasUtils.useWorkletCallback; - function getDeepDefaultProps({contentSize: contentSizeProp = {}, zoomRange: zoomRangeProp = {}}) { const contentSize = { width: contentSizeProp.width == null ? 1 : contentSizeProp.width, @@ -68,12 +64,12 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const offsetX = useSharedValue(0); const offsetY = useSharedValue(0); - const stopAnimation = useWorkletCallback(() => { + const stopAnimation = MultiGestureCanvasUtils.useWorkletCallback(() => { cancelAnimation(offsetX); cancelAnimation(offsetY); }); - const reset = useWorkletCallback((animated) => { + const reset = MultiGestureCanvasUtils.useWorkletCallback((animated) => { pinchScale.value = 1; stopAnimation(); @@ -81,13 +77,13 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr 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); + offsetX.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); + offsetY.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); + panTranslateX.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); + panTranslateY.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); + pinchTranslateX.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); + pinchTranslateY.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); + zoomScale.value = withSpring(1, MultiGestureCanvasUtils.SPRING_CONFIG); return; } @@ -222,4 +218,5 @@ MultiGestureCanvas.defaultProps = multiGestureCanvasDefaultProps; MultiGestureCanvas.displayName = 'MultiGestureCanvas'; export default MultiGestureCanvas; -export {defaultZoomRange, zoomScaleBounceFactors}; +export {defaultZoomRange}; +export {zoomScaleBounceFactors} from './utils'; diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index f43b67a0f1f4..f842d1fe4329 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -5,10 +5,6 @@ import * as MultiGestureCanvasUtils from './utils'; const PAN_DECAY_DECELARATION = 0.9915; -const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; -const clamp = MultiGestureCanvasUtils.clamp; -const useWorkletCallback = MultiGestureCanvasUtils.useWorkletCallback; - const usePanGesture = ({ canvasSize, contentSize, @@ -19,8 +15,8 @@ const usePanGesture = ({ zoomScale, zoomRange, totalScale, - totalOffsetX, - totalOffsetY, + offsetX, + offsetY, panTranslateX, panTranslateY, isSwipingInPager, @@ -40,7 +36,7 @@ const usePanGesture = ({ // 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(() => { + const getBounds = MultiGestureCanvasUtils.useWorkletCallback(() => { let rightBoundary = 0; let topBoundary = 0; @@ -56,12 +52,12 @@ const usePanGesture = ({ const minVector = {x: -rightBoundary, y: -topBoundary}; const target = { - x: clamp(totalOffsetX.value, minVector.x, maxVector.x), - y: clamp(totalOffsetY.value, minVector.y, maxVector.y), + x: MultiGestureCanvasUtils.clamp(offsetX.value, minVector.x, maxVector.x), + y: MultiGestureCanvasUtils.clamp(offsetY.value, minVector.y, maxVector.y), }; - const isInBoundaryX = target.x === totalOffsetX.value; - const isInBoundaryY = target.y === totalOffsetY.value; + const isInBoundaryX = target.x === offsetX.value; + const isInBoundaryY = target.y === offsetY.value; return { target, @@ -74,24 +70,24 @@ const usePanGesture = ({ }; }, [canvasSize.width, canvasSize.height]); - const returnToBoundaries = useWorkletCallback(() => { + const returnToBoundaries = MultiGestureCanvasUtils.useWorkletCallback(() => { const {target, isInBoundaryX, isInBoundaryY, minVector, maxVector} = getBounds(); - if (zoomScale.value === zoomRange.min && totalOffsetX.value === 0 && totalOffsetY.value === 0 && panTranslateX.value === 0 && panTranslateY.value === 0) { + if (zoomScale.value === zoomRange.min && offsetX.value === 0 && offsetY.value === 0 && panTranslateX.value === 0 && panTranslateY.value === 0) { // We don't need to run any animations return; } // If we are zoomed out, we want to center the content if (zoomScale.value <= zoomRange.min) { - totalOffsetX.value = withSpring(0, SPRING_CONFIG); - totalOffsetY.value = withSpring(0, SPRING_CONFIG); + offsetX.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); + offsetY.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); return; } if (isInBoundaryX) { if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= zoomRange.max) { - totalOffsetX.value = withDecay({ + offsetX.value = withDecay({ velocity: panVelocityX.value, clamp: [minVector.x, maxVector.x], deceleration: PAN_DECAY_DECELARATION, @@ -99,7 +95,7 @@ const usePanGesture = ({ }); } } else { - totalOffsetX.value = withSpring(target.x, SPRING_CONFIG); + offsetX.value = withSpring(target.x, MultiGestureCanvasUtils.SPRING_CONFIG); } if (isInBoundaryY) { @@ -107,17 +103,17 @@ const usePanGesture = ({ Math.abs(panVelocityY.value) > 0 && zoomScale.value <= zoomRange.max && // Limit vertical panning when content is smaller than screen - totalOffsetY.value !== minVector.y && - totalOffsetY.value !== maxVector.y + offsetY.value !== minVector.y && + offsetY.value !== maxVector.y ) { - totalOffsetY.value = withDecay({ + offsetY.value = withDecay({ velocity: panVelocityY.value, clamp: [minVector.y, maxVector.y], deceleration: PAN_DECAY_DECELARATION, }); } } else { - totalOffsetY.value = withSpring(target.y, SPRING_CONFIG); + offsetY.value = withSpring(target.y, MultiGestureCanvasUtils.SPRING_CONFIG); } }); @@ -157,8 +153,8 @@ const usePanGesture = ({ }) .onEnd(() => { // Add pan translation to total offset - totalOffsetX.value += panTranslateX.value; - totalOffsetY.value += panTranslateY.value; + offsetX.value += panTranslateX.value; + offsetY.value += panTranslateY.value; // Reset pan gesture variables panTranslateX.value = 0; diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 1756365cec88..3f79f8aedbf3 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -4,10 +4,6 @@ import {Gesture} from 'react-native-gesture-handler'; import {runOnJS, useAnimatedReaction, useSharedValue, withSpring} from 'react-native-reanimated'; import * as MultiGestureCanvasUtils from './utils'; -const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; -const zoomScaleBounceFactors = MultiGestureCanvasUtils.zoomScaleBounceFactors; -const useWorkletCallback = MultiGestureCanvasUtils.useWorkletCallback; - const usePinchGesture = ({ canvasSize, singleTapGesture, @@ -51,7 +47,7 @@ const usePinchGesture = ({ }, ); - const getAdjustedFocal = useWorkletCallback( + const getAdjustedFocal = MultiGestureCanvasUtils.useWorkletCallback( (focalX, focalY) => ({ x: focalX - (canvasSize.width / 2 + offsetX.value), y: focalY - (canvasSize.height / 2 + offsetY.value), @@ -82,7 +78,10 @@ const usePinchGesture = ({ const newZoomScale = pinchScale.value * evt.scale; // Limit zoom scale to zoom range including bounce range - if (zoomScale.value >= zoomRange.min * zoomScaleBounceFactors.min && zoomScale.value <= zoomRange.max * zoomScaleBounceFactors.max) { + if ( + zoomScale.value >= zoomRange.min * MultiGestureCanvasUtils.zoomScaleBounceFactors.min && + zoomScale.value <= zoomRange.max * MultiGestureCanvasUtils.zoomScaleBounceFactors.max + ) { zoomScale.value = newZoomScale; currentPinchScale.value = evt.scale; } @@ -115,18 +114,18 @@ const usePinchGesture = ({ // 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); + pinchBounceTranslateX.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); + pinchBounceTranslateY.value = withSpring(0, MultiGestureCanvasUtils.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); + zoomScale.value = withSpring(zoomRange.min, MultiGestureCanvasUtils.SPRING_CONFIG); } 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); + zoomScale.value = withSpring(zoomRange.max, MultiGestureCanvasUtils.SPRING_CONFIG); } else { // Otherwise, we just update the pinch scale offset pinchScale.value = zoomScale.value; diff --git a/src/components/MultiGestureCanvas/useTapGestures.js b/src/components/MultiGestureCanvas/useTapGestures.js index c2df4392f96c..3b64b02e56b5 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.js +++ b/src/components/MultiGestureCanvas/useTapGestures.js @@ -6,83 +6,94 @@ import * as MultiGestureCanvasUtils from './utils'; const DOUBLE_TAP_SCALE = 3; -const clamp = MultiGestureCanvasUtils.clamp; -const SPRING_CONFIG = MultiGestureCanvasUtils.SPRING_CONFIG; -const useWorkletCallback = MultiGestureCanvasUtils.useWorkletCallback; - const useTapGestures = ({canvasSize, contentSize, minContentScale, maxContentScale, panGestureRef, offsetX, offsetY, pinchScale, zoomScale, reset, stopAnimation, onScaleChanged, onTap}) => { - const scaledWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]); - const scaledHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]); + // 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 zoom to fill, but at least zoom by 3x + // 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( - (canvasFocalX, canvasFocalY) => { + const zoomToCoordinates = MultiGestureCanvasUtils.useWorkletCallback( + (focalX, focalY) => { 'worklet'; stopAnimation(); - const canvasOffsetX = Math.max(0, (canvasSize.width - scaledWidth) / 2); - const canvasOffsetY = Math.max(0, (canvasSize.height - scaledHeight) / 2); + // 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); - const contentFocal = { - x: clamp(canvasFocalX - canvasOffsetX, 0, scaledWidth), - y: clamp(canvasFocalY - canvasOffsetY, 0, scaledHeight), + // 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, }; - const originContentCenter = { - x: scaledWidth / 2, - y: scaledHeight / 2, + // The center of the content before zooming + const originalContentCenter = { + x: scaledContentWidth / 2, + y: scaledContentHeight / 2, }; - const targetContentSize = { - width: scaledWidth * doubleTapScale, - height: scaledHeight * doubleTapScale, + // The size of the content after zooming + const zoomedContentSize = { + width: scaledContentWidth * doubleTapScale, + height: scaledContentHeight * doubleTapScale, }; - const targetContentCenter = { - x: targetContentSize.width / 2, - y: targetContentSize.height / 2, + // The center of the zoomed content + const zoomedContentCenter = { + x: zoomedContentSize.width / 2, + y: zoomedContentSize.height / 2, }; - const currentOrigin = { - x: (targetContentCenter.x - canvasCenter.x) * -1, - y: (targetContentCenter.y - canvasCenter.y) * -1, + // By how much the zoomed content is bigger/smaller than the canvas. + const zoomedContentOffset = { + x: zoomedContentCenter.x - canvasCenter.x, + y: zoomedContentCenter.y - canvasCenter.y, }; - const koef = { - x: (1 / originContentCenter.x) * contentFocal.x - 1, - y: (1 / originContentCenter.y) * contentFocal.y - 1, + // 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, }; - const target = { - x: currentOrigin.x * koef.x, - y: currentOrigin.y * koef.y, + // 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 (targetContentSize.height < canvasSize.height) { - target.y = 0; + // 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(target.x, SPRING_CONFIG); - offsetY.value = withSpring(target.y, SPRING_CONFIG); - zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG); + offsetX.value = withSpring(offsetAfterZooming.x, MultiGestureCanvasUtils.SPRING_CONFIG); + offsetY.value = withSpring(offsetAfterZooming.y, MultiGestureCanvasUtils.SPRING_CONFIG); + zoomScale.value = withSpring(doubleTapScale, MultiGestureCanvasUtils.SPRING_CONFIG); pinchScale.value = doubleTapScale; }, - [scaledWidth, scaledHeight, canvasSize, doubleTapScale], + [scaledContentWidth, scaledContentHeight, canvasSize, doubleTapScale], ); - const doubleTap = Gesture.Tap() + const doubleTapGesture = Gesture.Tap() .numberOfTaps(2) .maxDelay(150) .maxDistance(20) .onEnd((evt) => { + // If the content is already zoomed, we want to reset the zoom, + // otherwwise we want to zoom in if (zoomScale.value > 1) { reset(true); } else { @@ -94,10 +105,10 @@ const useTapGestures = ({canvasSize, contentSize, minContentScale, maxContentSca } }); - const singleTap = Gesture.Tap() + const singleTapGesture = Gesture.Tap() .numberOfTaps(1) .maxDuration(50) - .requireExternalGestureToFail(doubleTap, panGestureRef) + .requireExternalGestureToFail(doubleTapGesture, panGestureRef) .onBegin(() => { stopAnimation(); }) @@ -109,7 +120,7 @@ const useTapGestures = ({canvasSize, contentSize, minContentScale, maxContentSca runOnJS(onTap)(); }); - return {singleTap, doubleTap}; + return {singleTapGesture, doubleTapGesture}; }; export default useTapGestures; From ba75ac922cde2585ecbf3c6037c7c94d34045ec3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 17:37:38 +0100 Subject: [PATCH 033/382] improve pan gesture code --- .../MultiGestureCanvas/usePanGesture.js | 84 ++++++++----------- 1 file changed, 35 insertions(+), 49 deletions(-) diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index f842d1fe4329..657b51661145 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -22,12 +22,9 @@ const usePanGesture = ({ isSwipingInPager, stopAnimation, }) => { - // The content size after scaling it with the current (total) zoom value - const zoomScaledContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]); - const zoomScaledContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]); - - // Used to track previous touch position for the "swipe down to close" gesture - const previousTouch = useSharedValue(null); + // 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]); // Pan velocity to calculate the decay const panVelocityX = useSharedValue(0); @@ -40,62 +37,57 @@ const usePanGesture = ({ let rightBoundary = 0; let topBoundary = 0; - if (canvasSize.width < zoomScaledContentWidth.value) { - rightBoundary = Math.abs(canvasSize.width - zoomScaledContentWidth.value) / 2; + if (canvasSize.width < zoomedContentWidth.value) { + rightBoundary = Math.abs(canvasSize.width - zoomedContentWidth.value) / 2; } - if (canvasSize.height < zoomScaledContentHeight.value) { - topBoundary = Math.abs(zoomScaledContentHeight.value - canvasSize.height) / 2; + if (canvasSize.height < zoomedContentHeight.value) { + topBoundary = Math.abs(zoomedContentHeight.value - canvasSize.height) / 2; } - const maxVector = {x: rightBoundary, y: topBoundary}; - const minVector = {x: -rightBoundary, y: -topBoundary}; + const minBoundaries = {x: -rightBoundary, y: -topBoundary}; + const maxBoundaries = {x: rightBoundary, y: topBoundary}; - const target = { - x: MultiGestureCanvasUtils.clamp(offsetX.value, minVector.x, maxVector.x), - y: MultiGestureCanvasUtils.clamp(offsetY.value, minVector.y, maxVector.y), + const clampedOffset = { + x: MultiGestureCanvasUtils.clamp(offsetX.value, minBoundaries.x, maxBoundaries.x), + y: MultiGestureCanvasUtils.clamp(offsetY.value, minBoundaries.y, maxBoundaries.y), }; - const isInBoundaryX = target.x === offsetX.value; - const isInBoundaryY = target.y === offsetY.value; + // If the horizontal/vertical offset is the same after clamping to the min/max boundaries, the content is within the boundaries + const isInBoundaryX = clampedOffset.x === offsetX.value; + const isInBoundaryY = clampedOffset.y === offsetY.value; return { - target, + minBoundaries, + maxBoundaries, + clampedOffset, isInBoundaryX, isInBoundaryY, - minVector, - maxVector, - canPanLeft: target.x < maxVector.x, - canPanRight: target.x > minVector.x, }; }, [canvasSize.width, canvasSize.height]); - const returnToBoundaries = MultiGestureCanvasUtils.useWorkletCallback(() => { - const {target, isInBoundaryX, isInBoundaryY, minVector, maxVector} = getBounds(); - - if (zoomScale.value === zoomRange.min && offsetX.value === 0 && offsetY.value === 0 && panTranslateX.value === 0 && panTranslateY.value === 0) { - // We don't need to run any animations + // We want to smoothly gesture by phasing out the pan animation + // In case the content is outside of the boundaries of the canvas, + // we need to return to the view to the boundaries + const finishPanGesture = MultiGestureCanvasUtils.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; } - // If we are zoomed out, we want to center the content - if (zoomScale.value <= zoomRange.min) { - offsetX.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); - offsetY.value = withSpring(0, MultiGestureCanvasUtils.SPRING_CONFIG); - return; - } + const {clampedOffset, isInBoundaryX, isInBoundaryY, minBoundaries, maxBoundaries} = getBounds(); if (isInBoundaryX) { if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= zoomRange.max) { offsetX.value = withDecay({ velocity: panVelocityX.value, - clamp: [minVector.x, maxVector.x], + clamp: [minBoundaries.x, maxBoundaries.x], deceleration: PAN_DECAY_DECELARATION, rubberBandEffect: false, }); } } else { - offsetX.value = withSpring(target.x, MultiGestureCanvasUtils.SPRING_CONFIG); + offsetX.value = withSpring(clampedOffset.x, MultiGestureCanvasUtils.SPRING_CONFIG); } if (isInBoundaryY) { @@ -103,17 +95,17 @@ const usePanGesture = ({ Math.abs(panVelocityY.value) > 0 && zoomScale.value <= zoomRange.max && // Limit vertical panning when content is smaller than screen - offsetY.value !== minVector.y && - offsetY.value !== maxVector.y + offsetY.value !== minBoundaries.y && + offsetY.value !== maxBoundaries.y ) { offsetY.value = withDecay({ velocity: panVelocityY.value, - clamp: [minVector.y, maxVector.y], + clamp: [minBoundaries.y, maxBoundaries.y], deceleration: PAN_DECAY_DECELARATION, }); } } else { - offsetY.value = withSpring(target.y, MultiGestureCanvasUtils.SPRING_CONFIG); + offsetY.value = withSpring(clampedOffset.y, MultiGestureCanvasUtils.SPRING_CONFIG); } }); @@ -121,16 +113,11 @@ const usePanGesture = ({ .manualActivation(true) .averageTouches(true) .onTouchesMove((evt, state) => { - if (zoomScale.value > 1) { - state.activate(); + if (zoomScale.value <= 1) { + return; } - if (previousTouch.value == null) { - previousTouch.value = { - x: evt.allTouches[0].x, - y: evt.allTouches[0].y, - }; - } + state.activate(); }) .simultaneousWithExternalGesture(pagerRef, singleTapGesture, doubleTapGesture) .onStart(() => { @@ -159,14 +146,13 @@ const usePanGesture = ({ // Reset pan gesture variables panTranslateX.value = 0; panTranslateY.value = 0; - previousTouch.value = null; // If we are swiping (in the pager), we don't want to return to boundaries if (isSwipingInPager.value) { return; } - returnToBoundaries(); + finishPanGesture(); // Reset pan gesture variables panVelocityX.value = 0; From 1de9b257d77ee2ddc6a34674ce5929f4e27e7550 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 17:38:24 +0100 Subject: [PATCH 034/382] fix: pinch gesture --- .../MultiGestureCanvas/usePinchGesture.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 3f79f8aedbf3..ba6d71e87df5 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -54,7 +54,17 @@ const usePinchGesture = ({ }), [canvasSize.width, canvasSize.height], ); + + 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 (!isSwipingInPager.value) { @@ -75,6 +85,11 @@ const usePinchGesture = ({ pinchOrigin.y.value = adjustedFocal.y; }) .onChange((evt) => { + if (evt.numberOfPointers !== 2) { + runOnJS(setPinchEnabled)(false); + return; + } + const newZoomScale = pinchScale.value * evt.scale; // Limit zoom scale to zoom range including bounce range @@ -103,8 +118,8 @@ const usePinchGesture = ({ }) .onEnd(() => { // Add pinch translation to total offset - offsetX.value += totalPinchTranslateX.value; - offsetY.value += totalPinchTranslateX.value; + offsetX.value += pinchTranslateX.value; + offsetY.value += pinchTranslateY.value; // Reset pinch gesture variables pinchTranslateX.value = 0; From 3d93b5e6fcf849e32f2ad18b64218001386f690b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 18:06:58 +0100 Subject: [PATCH 035/382] improve pan gesture code and remove inter-depdendencies of gestures --- src/components/MultiGestureCanvas/index.js | 17 ++-- .../MultiGestureCanvas/usePanGesture.js | 81 ++++++++----------- .../MultiGestureCanvas/usePinchGesture.js | 4 - .../MultiGestureCanvas/useTapGestures.js | 3 +- src/components/MultiGestureCanvas/utils.ts | 19 +++++ 5 files changed, 59 insertions(+), 65 deletions(-) diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index 0964f63913fd..0577ec79d0d6 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -96,7 +96,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr zoomScale.value = 1; }); - const {singleTapGesture, doubleTapGesture} = useTapGestures({ + const {singleTapGesture: basicSingleTapGesture, doubleTapGesture} = useTapGestures({ canvasSize, contentSize, minContentScale, @@ -111,16 +111,12 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr onScaleChanged, onTap, }); + const singleTapGesture = basicSingleTapGesture.requireExternalGestureToFail(doubleTapGesture, panGestureRef); const panGesture = usePanGesture({ canvasSize, contentSize, - singleTapGesture, - doubleTapGesture, - panGestureRef, - pagerRef, zoomScale, - zoomRange, totalScale, offsetX, offsetY, @@ -128,13 +124,12 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr panTranslateY, isSwipingInPager, stopAnimation, - }); + }) + .simultaneousWithExternalGesture(pagerRef, singleTapGesture, doubleTapGesture) + .withRef(panGestureRef); const pinchGesture = usePinchGesture({ canvasSize, - singleTapGesture, - doubleTapGesture, - panGesture, zoomScale, zoomRange, offsetX, @@ -146,7 +141,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr stopAnimation, onScaleChanged, onPinchGestureChange, - }); + }).simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture); // Enables/disables the pager scroll based on the zoom scale // When the content is zoomed in/out, the pager should be disabled diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index 657b51661145..07498e770aa5 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -5,23 +5,7 @@ import * as MultiGestureCanvasUtils from './utils'; const PAN_DECAY_DECELARATION = 0.9915; -const usePanGesture = ({ - canvasSize, - contentSize, - singleTapGesture, - doubleTapGesture, - panGestureRef, - pagerRef, - zoomScale, - zoomRange, - totalScale, - offsetX, - offsetY, - panTranslateX, - panTranslateY, - isSwipingInPager, - stopAnimation, -}) => { +const usePanGesture = ({canvasSize, contentSize, zoomScale, totalScale, offsetX, offsetY, panTranslateX, panTranslateY, isSwipingInPager, stopAnimation}) => { // 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]); @@ -34,35 +18,35 @@ const usePanGesture = ({ // Can we pan left/right/up/down // Can be used to limit gesture or implementing tension effect const getBounds = MultiGestureCanvasUtils.useWorkletCallback(() => { - let rightBoundary = 0; - let topBoundary = 0; + let horizontalBoundary = 0; + let verticalBoundary = 0; if (canvasSize.width < zoomedContentWidth.value) { - rightBoundary = Math.abs(canvasSize.width - zoomedContentWidth.value) / 2; + horizontalBoundary = Math.abs(canvasSize.width - zoomedContentWidth.value) / 2; } if (canvasSize.height < zoomedContentHeight.value) { - topBoundary = Math.abs(zoomedContentHeight.value - canvasSize.height) / 2; + verticalBoundary = Math.abs(zoomedContentHeight.value - canvasSize.height) / 2; } - const minBoundaries = {x: -rightBoundary, y: -topBoundary}; - const maxBoundaries = {x: rightBoundary, y: topBoundary}; + const horizontalBoundaries = {min: -horizontalBoundary, max: horizontalBoundary}; + const verticalBoundaries = {min: -verticalBoundary, max: verticalBoundary}; const clampedOffset = { - x: MultiGestureCanvasUtils.clamp(offsetX.value, minBoundaries.x, maxBoundaries.x), - y: MultiGestureCanvasUtils.clamp(offsetY.value, minBoundaries.y, maxBoundaries.y), + 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 isInBoundaryX = clampedOffset.x === offsetX.value; - const isInBoundaryY = clampedOffset.y === offsetY.value; + const isInHoriztontalBoundary = clampedOffset.x === offsetX.value; + const isInVerticalBoundary = clampedOffset.y === offsetY.value; return { - minBoundaries, - maxBoundaries, + horizontalBoundaries, + verticalBoundaries, clampedOffset, - isInBoundaryX, - isInBoundaryY, + isInHoriztontalBoundary, + isInVerticalBoundary, }; }, [canvasSize.width, canvasSize.height]); @@ -75,36 +59,38 @@ const usePanGesture = ({ return; } - const {clampedOffset, isInBoundaryX, isInBoundaryY, minBoundaries, maxBoundaries} = getBounds(); + const {clampedOffset, isInHoriztontalBoundary, isInVerticalBoundary, horizontalBoundaries, verticalBoundaries} = getBounds(); - if (isInBoundaryX) { - if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= zoomRange.max) { + // 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: [minBoundaries.x, maxBoundaries.x], + clamp: [horizontalBoundaries.min, horizontalBoundaries.max], deceleration: PAN_DECAY_DECELARATION, rubberBandEffect: false, }); } } else { + // Animated back to the boundary offsetX.value = withSpring(clampedOffset.x, MultiGestureCanvasUtils.SPRING_CONFIG); } - if (isInBoundaryY) { - if ( - Math.abs(panVelocityY.value) > 0 && - zoomScale.value <= zoomRange.max && - // Limit vertical panning when content is smaller than screen - offsetY.value !== minBoundaries.y && - offsetY.value !== maxBoundaries.y - ) { + 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: [minBoundaries.y, maxBoundaries.y], + clamp: [verticalBoundaries.min, verticalBoundaries.max], deceleration: PAN_DECAY_DECELARATION, }); } } else { + // Animated back to the boundary offsetY.value = withSpring(clampedOffset.y, MultiGestureCanvasUtils.SPRING_CONFIG); } }); @@ -112,14 +98,14 @@ const usePanGesture = ({ const panGesture = Gesture.Pan() .manualActivation(true) .averageTouches(true) - .onTouchesMove((evt, state) => { + .onTouchesMove((_evt, state) => { + // We only allow panning when the content is zoomed in if (zoomScale.value <= 1) { return; } state.activate(); }) - .simultaneousWithExternalGesture(pagerRef, singleTapGesture, doubleTapGesture) .onStart(() => { stopAnimation(); }) @@ -157,8 +143,7 @@ const usePanGesture = ({ // Reset pan gesture variables panVelocityX.value = 0; panVelocityY.value = 0; - }) - .withRef(panGestureRef); + }); return panGesture; }; diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index ba6d71e87df5..f40c58955c32 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -6,9 +6,6 @@ import * as MultiGestureCanvasUtils from './utils'; const usePinchGesture = ({ canvasSize, - singleTapGesture, - doubleTapGesture, - panGesture, zoomScale, zoomRange, offsetX, @@ -73,7 +70,6 @@ const usePinchGesture = ({ state.fail(); }) - .simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture) .onStart((evt) => { isPinchGestureRunning.value = true; diff --git a/src/components/MultiGestureCanvas/useTapGestures.js b/src/components/MultiGestureCanvas/useTapGestures.js index 3b64b02e56b5..eefe8c506b33 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.js +++ b/src/components/MultiGestureCanvas/useTapGestures.js @@ -6,7 +6,7 @@ import * as MultiGestureCanvasUtils from './utils'; const DOUBLE_TAP_SCALE = 3; -const useTapGestures = ({canvasSize, contentSize, minContentScale, maxContentScale, panGestureRef, offsetX, offsetY, pinchScale, zoomScale, reset, stopAnimation, onScaleChanged, onTap}) => { +const useTapGestures = ({canvasSize, contentSize, minContentScale, maxContentScale, offsetX, offsetY, pinchScale, zoomScale, reset, stopAnimation, onScaleChanged, onTap}) => { // 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]); @@ -108,7 +108,6 @@ const useTapGestures = ({canvasSize, contentSize, minContentScale, maxContentSca const singleTapGesture = Gesture.Tap() .numberOfTaps(1) .maxDuration(50) - .requireExternalGestureToFail(doubleTapGesture, panGestureRef) .onBegin(() => { stopAnimation(); }) diff --git a/src/components/MultiGestureCanvas/utils.ts b/src/components/MultiGestureCanvas/utils.ts index 7a4ba21358c4..5cddd009117a 100644 --- a/src/components/MultiGestureCanvas/utils.ts +++ b/src/components/MultiGestureCanvas/utils.ts @@ -1,22 +1,41 @@ import {useCallback} from 'react'; +// 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 = { mass: 1, stiffness: 1000, damping: 500, }; +// The zoom scale 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 zoomScaleBounceFactors = { min: 0.7, max: 1.5, }; +/** + * Clamps a value between a lower and upper bound + * @param value + * @param lowerBound + * @param upperBound + * @returns + */ function clamp(value: number, lowerBound: number, upperBound: number) { 'worklet'; return Math.min(Math.max(lowerBound, value), upperBound); } +/** + * Creates a memoized callback on the UI thread + * Same as `useWorkletCallback` from `react-native-reanimated` but without the deprecation warning + * @param callback + * @param deps + * @returns + */ const useWorkletCallback = (callback: Parameters[0], deps: Parameters[1] = []) => { 'worklet'; From f0c542bef393ad1a612fbbe0eac4d66481246f51 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 18:11:16 +0100 Subject: [PATCH 036/382] improve pan gesture code --- .../MultiGestureCanvas/usePanGesture.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index 07498e770aa5..8ab2078466de 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -93,6 +93,10 @@ const usePanGesture = ({canvasSize, contentSize, zoomScale, totalScale, offsetX, // Animated back to the boundary offsetY.value = withSpring(clampedOffset.y, MultiGestureCanvasUtils.SPRING_CONFIG); } + + // Reset velocity variables after we finished the pan gesture + panVelocityX.value = 0; + panVelocityY.value = 0; }); const panGesture = Gesture.Pan() @@ -100,7 +104,7 @@ const usePanGesture = ({canvasSize, contentSize, zoomScale, totalScale, offsetX, .averageTouches(true) .onTouchesMove((_evt, state) => { // We only allow panning when the content is zoomed in - if (zoomScale.value <= 1) { + if (zoomScale.value <= 1 || isSwipingInPager.value) { return; } @@ -111,9 +115,8 @@ const usePanGesture = ({canvasSize, contentSize, zoomScale, totalScale, offsetX, }) .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 AND move fingers - // since we track it as pinch focal gesture. - // We also need to prevent panning when we are swiping horizontally (from page to page) + // we need to make sure that we don't pan when we pinch since we track it as pinch focal gesture. + // We also need to prevent panning when we are swiping horizontally in the pager if (evt.numberOfPointers > 1 || isSwipingInPager.value) { return; } @@ -125,11 +128,9 @@ const usePanGesture = ({canvasSize, contentSize, zoomScale, totalScale, offsetX, panTranslateY.value += evt.changeY; }) .onEnd(() => { - // Add pan translation to total offset + // Add pan translation to total offset and reset gesture variables offsetX.value += panTranslateX.value; offsetY.value += panTranslateY.value; - - // Reset pan gesture variables panTranslateX.value = 0; panTranslateY.value = 0; @@ -139,10 +140,6 @@ const usePanGesture = ({canvasSize, contentSize, zoomScale, totalScale, offsetX, } finishPanGesture(); - - // Reset pan gesture variables - panVelocityX.value = 0; - panVelocityY.value = 0; }); return panGesture; From 58fe25b485ba7b3d6617df2b30757ac5c8368167 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 18:20:38 +0100 Subject: [PATCH 037/382] simplify pinch gesture callback --- .../MultiGestureCanvas/usePinchGesture.js | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index f40c58955c32..2d0b836623a6 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -18,8 +18,6 @@ const usePinchGesture = ({ onScaleChanged, onPinchGestureChange, }) => { - const isPinchGestureRunning = useSharedValue(false); - // Used to store event scale value when we limit scale const currentPinchScale = useSharedValue(1); @@ -71,8 +69,6 @@ const usePinchGesture = ({ state.fail(); }) .onStart((evt) => { - isPinchGestureRunning.value = true; - stopAnimation(); const adjustedFocal = getAdjustedFocal(evt.focalX, evt.focalY); @@ -121,7 +117,6 @@ const usePinchGesture = ({ pinchTranslateX.value = 0; pinchTranslateY.value = 0; currentPinchScale.value = 1; - isPinchGestureRunning.value = false; // If the content was "overzoomed" or "underzoomed", we need to bounce back with an animation if (pinchBounceTranslateX.value !== 0 || pinchBounceTranslateY.value !== 0) { @@ -148,19 +143,18 @@ const usePinchGesture = ({ }); // The "useAnimatedReaction" triggers a state update to run the "onPinchGestureChange" only once per re-render - const [isPinchGestureInUse, setIsPinchGestureInUse] = useState(false); + const [isPinchGestureRunning, setIsPinchGestureRunning] = useState(false); useAnimatedReaction( () => [zoomScale.value, isPinchGestureRunning.value], - ([zoom, running]) => { - const newIsPinchGestureInUse = zoom !== 1 || running; - if (isPinchGestureInUse !== newIsPinchGestureInUse) { - runOnJS(setIsPinchGestureInUse)(newIsPinchGestureInUse); + ([zoom]) => { + const newIsPinchGestureInUse = zoom !== 1; + if (isPinchGestureRunning !== newIsPinchGestureInUse) { + runOnJS(setIsPinchGestureRunning)(newIsPinchGestureInUse); } }, ); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => onPinchGestureChange(isPinchGestureInUse), [isPinchGestureInUse]); + useEffect(() => onPinchGestureChange(isPinchGestureRunning), [isPinchGestureRunning, onPinchGestureChange]); return pinchGesture; }; From 60b6404c3cdabc6b1e23b980770bd9ed57121ef2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 18:28:40 +0100 Subject: [PATCH 038/382] improve comments --- .../MultiGestureCanvas/usePanGesture.js | 3 +- .../MultiGestureCanvas/usePinchGesture.js | 35 +++++++++++++------ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/components/MultiGestureCanvas/usePanGesture.js b/src/components/MultiGestureCanvas/usePanGesture.js index 8ab2078466de..4ab872394cb2 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.js +++ b/src/components/MultiGestureCanvas/usePanGesture.js @@ -116,8 +116,7 @@ const usePanGesture = ({canvasSize, contentSize, zoomScale, totalScale, offsetX, .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. - // We also need to prevent panning when we are swiping horizontally in the pager - if (evt.numberOfPointers > 1 || isSwipingInPager.value) { + if (evt.numberOfPointers > 1) { return; } diff --git a/src/components/MultiGestureCanvas/usePinchGesture.js b/src/components/MultiGestureCanvas/usePinchGesture.js index 2d0b836623a6..54dd2da7943e 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.js +++ b/src/components/MultiGestureCanvas/usePinchGesture.js @@ -18,7 +18,7 @@ const usePinchGesture = ({ onScaleChanged, onPinchGestureChange, }) => { - // Used to store event scale value when we limit scale + // The current pinch gesture event scale const currentPinchScale = useSharedValue(1); // Origin of the pinch gesture @@ -27,13 +27,18 @@ const usePinchGesture = ({ 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 pinching over/under the min/max zoom scale + + // 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); + // Update the total (pinch) translation based on the regular pinch + bounce useAnimatedReaction( () => [pinchTranslateX.value, pinchTranslateY.value, pinchBounceTranslateX.value, pinchBounceTranslateY.value], ([translateX, translateY, bounceX, bounceY]) => { @@ -42,6 +47,10 @@ const usePinchGesture = ({ }, ); + /** + * Calculates the adjusted focal point of the pinch gesture, + * based on the canvas size and the current offset + */ const getAdjustedFocal = MultiGestureCanvasUtils.useWorkletCallback( (focalX, focalY) => ({ x: focalX - (canvasSize.width / 2 + offsetX.value), @@ -50,6 +59,8 @@ const usePinchGesture = ({ [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) { @@ -60,8 +71,8 @@ const usePinchGesture = ({ const pinchGesture = Gesture.Pinch() .enabled(pinchEnabled) - .onTouchesDown((evt, state) => { - // We don't want to activate pinch gesture when we are scrolling pager + .onTouchesDown((_evt, state) => { + // We don't want to activate pinch gesture when we are swiping in the pager if (!isSwipingInPager.value) { return; } @@ -71,12 +82,14 @@ const usePinchGesture = ({ .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; @@ -84,7 +97,7 @@ const usePinchGesture = ({ const newZoomScale = pinchScale.value * evt.scale; - // Limit zoom scale to zoom range including bounce range + // Limit the zoom scale to zoom range including bounce range if ( zoomScale.value >= zoomRange.min * MultiGestureCanvasUtils.zoomScaleBounceFactors.min && zoomScale.value <= zoomRange.max * MultiGestureCanvasUtils.zoomScaleBounceFactors.max @@ -98,6 +111,8 @@ const usePinchGesture = ({ 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; @@ -109,11 +124,9 @@ const usePinchGesture = ({ } }) .onEnd(() => { - // Add pinch translation to total offset + // Add pinch translation to total offset and reset gesture variables offsetX.value += pinchTranslateX.value; offsetY.value += pinchTranslateY.value; - - // Reset pinch gesture variables pinchTranslateX.value = 0; pinchTranslateY.value = 0; currentPinchScale.value = 1; @@ -142,7 +155,8 @@ const usePinchGesture = ({ } }); - // The "useAnimatedReaction" triggers a state update to run the "onPinchGestureChange" only once per re-render + // The "useAnimatedReaction" triggers a state update only when the value changed, + // which then triggers the "onPinchGestureChange" callback const [isPinchGestureRunning, setIsPinchGestureRunning] = useState(false); useAnimatedReaction( () => [zoomScale.value, isPinchGestureRunning.value], @@ -153,7 +167,6 @@ const usePinchGesture = ({ } }, ); - useEffect(() => onPinchGestureChange(isPinchGestureRunning), [isPinchGestureRunning, onPinchGestureChange]); return pinchGesture; From b1b592a60642b6bf226de80a524f93554577c832 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 3 Jan 2024 18:35:10 +0100 Subject: [PATCH 039/382] add more comments --- src/components/MultiGestureCanvas/index.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/MultiGestureCanvas/index.js b/src/components/MultiGestureCanvas/index.js index 0577ec79d0d6..128f1100b338 100644 --- a/src/components/MultiGestureCanvas/index.js +++ b/src/components/MultiGestureCanvas/index.js @@ -34,6 +34,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); const pagerRefFallback = useRef(null); + // If the MultiGestureCanvas used inside a AttachmentCarouselPager, we need to adapt the behaviour based on the pager state const {onTap, pagerRef, shouldPagerScroll, isSwipingInPager, onPinchGestureChange} = attachmentCarouselPagerContext || { onTap: () => undefined, onPinchGestureChange: () => undefined, @@ -43,6 +44,9 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr ...props, }; + // 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(() => getCanvasFitScale({canvasSize, contentSize}), [canvasSize, contentSize]); const zoomScale = useSharedValue(1); @@ -64,11 +68,17 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr const offsetX = useSharedValue(0); const offsetY = useSharedValue(0); + /** + * Stops any currently running decay animation from panning + */ const stopAnimation = MultiGestureCanvasUtils.useWorkletCallback(() => { cancelAnimation(offsetX); cancelAnimation(offsetY); }); + /** + * Resets the canvas to the initial state and animates back smoothly + */ const reset = MultiGestureCanvasUtils.useWorkletCallback((animated) => { pinchScale.value = 1; @@ -152,6 +162,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr }, ); + // Trigger a reset when the canvas gets inactive, but only if it was already mounted before const mounted = useRef(false); useEffect(() => { if (!mounted.current) { @@ -164,6 +175,7 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr } }, [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; From 8856caf3e53be49289c34476d3c780d713cacbd5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 4 Jan 2024 00:05:56 +0100 Subject: [PATCH 040/382] fix: eslint --- .../Attachments/AttachmentCarousel/Pager/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index 693c9b86fae9..2f86bb98f796 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -1,8 +1,11 @@ import React, {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import {createNativeWrapper, NativeViewGestureHandlerProps} from 'react-native-gesture-handler'; -import PagerView, {PagerViewProps} from 'react-native-pager-view'; -import Animated, {AnimatedProps, runOnJS, useAnimatedProps, useAnimatedReaction, useSharedValue} from 'react-native-reanimated'; +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 type {AnimatedProps} from 'react-native-reanimated'; +import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useSharedValue} from 'react-native-reanimated'; import useThemeStyles from '@hooks/useThemeStyles'; import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext'; import usePageScrollHandler from './usePageScrollHandler'; From b6bbdece8beda8ea9cc4bea91d81f3fb29b30028 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 4 Jan 2024 00:10:04 +0100 Subject: [PATCH 041/382] fix: eslint --- .../Pager/AttachmentCarouselPagerContext.ts | 4 ++-- .../AttachmentCarousel/Pager/usePageScrollHandler.ts | 2 +- src/components/Lightbox.js | 7 ++++--- src/components/MultiGestureCanvas/index.tsx | 7 ++++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts index 85f3e6adbda5..846cafb7d443 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts @@ -1,6 +1,6 @@ import {createContext} from 'react'; -import PagerView from 'react-native-pager-view'; -import {SharedValue} from 'react-native-reanimated'; +import type PagerView from 'react-native-pager-view'; +import type {SharedValue} from 'react-native-reanimated'; type AttachmentCarouselPagerContextType = { onTap: () => void; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts b/src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts index 9841129d036c..bcc616883d72 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/usePageScrollHandler.ts @@ -1,4 +1,4 @@ -import {PagerViewProps} from 'react-native-pager-view'; +import type {PagerViewProps} from 'react-native-pager-view'; import {useEvent, useHandler} from 'react-native-reanimated'; type PageScrollHandler = NonNullable; diff --git a/src/components/Lightbox.js b/src/components/Lightbox.js index c78a3569d73a..64d6e25c9c36 100644 --- a/src/components/Lightbox.js +++ b/src/components/Lightbox.js @@ -6,7 +6,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import * as AttachmentsPropTypes from './Attachments/propTypes'; import Image from './Image'; import MultiGestureCanvas from './MultiGestureCanvas'; -import {zoomRangeDefaultProps, zoomRangePropTypes} from './MultiGestureCanvas/propTypes'; import getCanvasFitScale from './MultiGestureCanvas/utils'; // Increase/decrease this number to change the number of concurrent lightboxes @@ -20,7 +19,8 @@ const cachedDimensions = new Map(); * On the native layer, we use a image library to handle zoom functionality */ const propTypes = { - ...zoomRangePropTypes, + // TODO: Add TS types for zoom range + // ...zoomRangePropTypes, /** Function for handle on press */ onPress: PropTypes.func, @@ -48,7 +48,8 @@ const propTypes = { }; const defaultProps = { - ...zoomRangeDefaultProps, + // TODO: Add TS default values + // ...zoomRangeDefaultProps, isAuthTokenRequired: false, index: 0, diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index c3760434fa97..21624f235092 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -1,14 +1,15 @@ import React, {useContext, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import PagerView from 'react-native-pager-view'; +import type PagerView from 'react-native-pager-view'; import Animated, {cancelAnimation, runOnUI, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring} from 'react-native-reanimated'; -import AttachmentCarouselPagerContext, {AttachmentCarouselPagerContextType} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import type {AttachmentCarouselPagerContextType} from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {defaultZoomRange} from './constants'; import getCanvasFitScale from './getCanvasFitScale'; -import {ContentSizeProp, ZoomRangeProp} from './types'; +import type {ContentSizeProp, ZoomRangeProp} from './types'; import usePanGesture from './usePanGesture'; import usePinchGesture from './usePinchGesture'; import useTapGestures from './useTapGestures'; From a9aaa2857eb7cce120f30564237a75d190d585db Mon Sep 17 00:00:00 2001 From: Jakub Butkiewicz Date: Thu, 4 Jan 2024 09:49:42 +0100 Subject: [PATCH 042/382] ref: started AttachemntModal migration --- ...AttachmentModal.js => AttachmentModal.tsx} | 327 ++++++++---------- src/libs/TransactionUtils.ts | 4 +- 2 files changed, 154 insertions(+), 177 deletions(-) rename src/components/{AttachmentModal.js => AttachmentModal.tsx} (64%) diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.tsx similarity index 64% rename from src/components/AttachmentModal.js rename to src/components/AttachmentModal.tsx index 51912c04eb31..80c2428b534a 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.tsx @@ -1,12 +1,11 @@ import Str from 'expensify-common/lib/str'; import lodashExtend from 'lodash/extend'; -import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {Animated, Keyboard, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import {OnyxEntry, withOnyx} from 'react-native-onyx'; +import {ValueOf} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -14,7 +13,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; -import compose from '@libs/compose'; import fileDownload from '@libs/fileDownload'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -22,11 +20,15 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import useNativeDriver from '@libs/useNativeDriver'; +import {AvatarSource} from '@libs/UserUtils'; import reportPropTypes from '@pages/reportPropTypes'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; +import {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import * as OnyxTypes from '@src/types/onyx'; +import {isNotEmptyObject} from '@src/types/utils/EmptyObject'; import AttachmentCarousel from './Attachments/AttachmentCarousel'; import AttachmentView from './Attachments/AttachmentView'; import Button from './Button'; @@ -37,108 +39,92 @@ import * as Expensicons from './Icon/Expensicons'; import sourcePropTypes from './Image/sourcePropTypes'; import Modal from './Modal'; import SafeAreaConsumer from './SafeAreaConsumer'; -import transactionPropTypes from './transactionPropTypes'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; /** * Modal render prop component that exposes modal launching triggers that can be used * to display a full size image or PDF modally with optional confirmation button. */ -const propTypes = { - /** Optional source (URL, SVG function) for the image shown. If not passed in via props must be specified when modal is opened. */ - source: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]), - - /** Optional callback to fire when we want to preview an image and approve it for use. */ - onConfirm: PropTypes.func, - - /** Whether the modal should be open by default */ - defaultOpen: PropTypes.bool, - - /** Optional callback to fire when we want to do something after modal show. */ - onModalShow: PropTypes.func, - - /** Optional callback to fire when we want to do something after modal hide. */ - onModalHide: PropTypes.func, - - /** Optional callback to fire when we want to do something after attachment carousel changes. */ - onCarouselAttachmentChange: PropTypes.func, - - /** Optional original filename when uploading */ - originalFileName: PropTypes.string, - - /** A function as a child to pass modal launching methods to */ - children: PropTypes.func, - - /** Whether source url requires authentication */ - isAuthTokenRequired: PropTypes.bool, - - /** Determines if download Button should be shown or not */ - allowDownload: PropTypes.bool, - - /** Title shown in the header of the modal */ - headerTitle: PropTypes.string, - - /** The report that has this attachment */ - report: reportPropTypes, - - /** The transaction associated with the receipt attachment, if any */ - transaction: transactionPropTypes, - - ...withLocalizePropTypes, - - ...windowDimensionsPropTypes, +type AttachmentModalOnyxProps = { + transaction: OnyxEntry; + parentReport: OnyxEntry; + policy: OnyxEntry; + parentReportActions: OnyxEntry; + session: OnyxEntry; +}; - /** Denotes whether it is a workspace avatar or not */ - isWorkspaceAvatar: PropTypes.bool, +type File = { + name: string; +}; - /** Whether it is a receipt attachment or not */ - isReceiptAttachment: PropTypes.bool, +type ChildrenProps = { + displayFileInModal: (data: any) => void; + show: () => void; }; -const defaultProps = { - source: '', - onConfirm: null, - defaultOpen: false, - originalFileName: '', - children: null, - isAuthTokenRequired: false, - allowDownload: false, - headerTitle: null, - report: {}, - transaction: {}, - onModalShow: () => {}, - onModalHide: () => {}, - onCarouselAttachmentChange: () => {}, - isWorkspaceAvatar: false, - isReceiptAttachment: false, +type AttachmentModalProps = AttachmentModalOnyxProps & { + source?: string | AvatarSource | number; + onConfirm?: ((file: File) => void) | null; + defaultOpen?: boolean; + originalFileName?: string; + isAuthTokenRequired?: boolean; + allowDownload?: boolean; + headerTitle?: string; + report?: OnyxTypes.Report; + onModalShow?: () => void; + onModalHide?: (e: any) => void; + onCarouselAttachmentChange?: (attachment: {source: string; isAuthTokenRequired: boolean; file: {name: string}; isReceipt: boolean}) => void; + isWorkspaceAvatar?: boolean; + isReceiptAttachment?: boolean; + children?: React.FC; + fallbackSource?: string | AvatarSource | number; }; -function AttachmentModal(props) { +function AttachmentModal({ + source = '', + onConfirm = null, + defaultOpen = false, + originalFileName = '', + isAuthTokenRequired = false, + allowDownload = false, + report, + onModalShow = () => {}, + onModalHide = () => {}, + onCarouselAttachmentChange = () => {}, + isReceiptAttachment = false, + isWorkspaceAvatar = false, + transaction, + parentReport, + parentReportActions, + headerTitle, + policy, + children, + fallbackSource, +}: AttachmentModalProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const [isModalOpen, setIsModalOpen] = useState(props.defaultOpen); + const [isModalOpen, setIsModalOpen] = useState(defaultOpen); const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); - const [isAuthTokenRequired, setIsAuthTokenRequired] = useState(props.isAuthTokenRequired); + const [isAuthTokenRequiredState, setIsAuthTokenRequiredState] = useState(isAuthTokenRequired); const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(''); - const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null); - const [source, setSource] = useState(props.source); - const [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE); + const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null); + const [sourceState, setSourceState] = useState(source); + const [modalType, setModalType] = useState>(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE); const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); const [confirmButtonFadeAnimation] = useState(() => new Animated.Value(1)); const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = React.useState(true); - const {windowWidth} = useWindowDimensions(); + const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); - const isOverlayModalVisible = (props.isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!props.isReceiptAttachment && isAttachmentInvalid); + const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && isAttachmentInvalid); const [file, setFile] = useState( - props.originalFileName + originalFileName ? { - name: props.originalFileName, + name: originalFileName, } : undefined, ); @@ -146,10 +132,8 @@ function AttachmentModal(props) { const {isOffline} = useNetwork(); useEffect(() => { - setFile(props.originalFileName ? {name: props.originalFileName} : undefined); - }, [props.originalFileName]); - - const onCarouselAttachmentChange = props.onCarouselAttachmentChange; + setFile(originalFileName ? {name: originalFileName} : undefined); + }, [originalFileName]); /** * Keeps the attachment source in sync with the attachment displayed currently in the carousel. @@ -157,9 +141,9 @@ function AttachmentModal(props) { */ const onNavigate = useCallback( (attachment) => { - setSource(attachment.source); + setSourceState(attachment.source); setFile(attachment.file); - setIsAuthTokenRequired(attachment.isAuthTokenRequired); + setIsAuthTokenRequiredState(attachment.isAuthTokenRequired); onCarouselAttachmentChange(attachment); }, [onCarouselAttachmentChange], @@ -180,7 +164,7 @@ function AttachmentModal(props) { ); const setDownloadButtonVisibility = useCallback( - (isButtonVisible) => { + (isButtonVisible: boolean) => { if (isDownloadButtonReadyToBeShown === isButtonVisible) { return; } @@ -193,17 +177,17 @@ function AttachmentModal(props) { * Download the currently viewed attachment. */ const downloadAttachment = useCallback(() => { - let sourceURL = source; - if (isAuthTokenRequired) { - sourceURL = addEncryptedAuthTokenToURL(sourceURL); + let sourceURL = sourceState; + if (isAuthTokenRequiredState) { + sourceURL = addEncryptedAuthTokenToURL(sourceURL ?? ''); } - fileDownload(sourceURL, lodashGet(file, 'name', '')); + fileDownload(sourceURL, file?.name ?? ''); // At ios, if the keyboard is open while opening the attachment, then after downloading // the attachment keyboard will show up. So, to fix it we need to dismiss the keyboard. Keyboard.dismiss(); - }, [isAuthTokenRequired, source, file]); + }, [isAuthTokenRequiredState, sourceState, file]); /** * Execute the onConfirm callback and close the modal. @@ -215,13 +199,13 @@ function AttachmentModal(props) { return; } - if (props.onConfirm) { - props.onConfirm(lodashExtend(file, {source})); + if (onConfirm) { + onConfirm(lodashExtend(file, {sourceState})); } setIsModalOpen(false); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isModalOpen, isConfirmButtonDisabled, props.onConfirm, file, source]); + }, [isModalOpen, isConfirmButtonDisabled, onConfirm, file, sourceState]); /** * Close the confirm modals. @@ -235,24 +219,24 @@ function AttachmentModal(props) { * Detach the receipt and close the modal. */ const deleteAndCloseModal = useCallback(() => { - IOU.detachReceipt(props.transaction.transactionID, props.report.reportID); + IOU.detachReceipt(transaction?.transactionID); setIsDeleteReceiptConfirmModalVisible(false); - Navigation.dismissModal(props.report.reportID); - }, [props.transaction, props.report]); + Navigation.dismissModal(report?.reportID); + }, [transaction, report]); /** * @param {Object} _file * @returns {Boolean} */ const isValidFile = useCallback((_file) => { - if (lodashGet(_file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + if ((_file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { setIsAttachmentInvalid(true); setAttachmentInvalidReasonTitle('attachmentPicker.attachmentTooLarge'); setAttachmentInvalidReason('attachmentPicker.sizeExceeded'); return false; } - if (lodashGet(_file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + if ((_file.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { setIsAttachmentInvalid(true); setAttachmentInvalidReasonTitle('attachmentPicker.attachmentTooSmall'); setAttachmentInvalidReason('attachmentPicker.sizeNotMet'); @@ -308,13 +292,13 @@ function AttachmentModal(props) { const inputSource = URL.createObjectURL(updatedFile); const inputModalType = getModalType(inputSource, updatedFile); setIsModalOpen(true); - setSource(inputSource); + setSourceState(inputSource); setFile(updatedFile); setModalType(inputModalType); } else { const inputModalType = getModalType(fileObject.uri, fileObject); setIsModalOpen(true); - setSource(fileObject.uri); + setSourceState(fileObject.uri); setFile(fileObject); setModalType(inputModalType); } @@ -331,7 +315,7 @@ function AttachmentModal(props) { * @param {Boolean} shouldFadeOut If true, fade out confirm button. Otherwise fade in. */ const updateConfirmButtonVisibility = useCallback( - (shouldFadeOut) => { + (shouldFadeOut: boolean) => { setIsConfirmButtonDisabled(shouldFadeOut); const toValue = shouldFadeOut ? 0 : 1; @@ -359,44 +343,44 @@ function AttachmentModal(props) { }, []); useEffect(() => { - setSource(props.source); - }, [props.source]); + setSourceState(source); + }, [source]); useEffect(() => { - setIsAuthTokenRequired(props.isAuthTokenRequired); - }, [props.isAuthTokenRequired]); + setIsAuthTokenRequiredState(isAuthTokenRequired); + }, [isAuthTokenRequired]); - const sourceForAttachmentView = props.source || source; + const sourceForAttachmentView = source || source; const threeDotsMenuItems = useMemo(() => { - if (!props.isReceiptAttachment || !props.parentReport || !props.parentReportActions) { + if (!isReceiptAttachment || !parentReport || !parentReportActions) { return []; } const menuItems = []; - const parentReportAction = props.parentReportActions[props.report.parentReportActionID]; + const parentReportAction = parentReportActions[report?.parentReportActionID ?? '']; const canEdit = - ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, props.parentReport.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT, props.transaction) && - !TransactionUtils.isDistanceRequest(props.transaction); + ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, parentReport.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction) && + !TransactionUtils.isDistanceRequest(transaction); if (canEdit) { menuItems.push({ icon: Expensicons.Camera, - text: props.translate('common.replace'), + text: translate('common.replace'), onSelected: () => { closeModal(); - Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT)); + Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report?.reportID ?? '', CONST.EDIT_REQUEST_FIELD.RECEIPT)); }, }); } menuItems.push({ icon: Expensicons.Download, - text: props.translate('common.download'), - onSelected: () => downloadAttachment(source), + text: translate('common.download'), + onSelected: () => downloadAttachment(sourceState), }); - if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction) && canEdit) { + if (TransactionUtils.hasReceipt(transaction) && !TransactionUtils.isReceiptBeingScanned(transaction) && canEdit) { menuItems.push({ icon: Expensicons.Trashcan, - text: props.translate('receipt.deleteReceipt'), + text: translate('receipt.deleteReceipt'), onSelected: () => { setIsDeleteReceiptConfirmModalVisible(true); }, @@ -404,17 +388,17 @@ function AttachmentModal(props) { } return menuItems; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.isReceiptAttachment, props.parentReport, props.parentReportActions, props.policy, props.transaction, file, source]); + }, [isReceiptAttachment, parentReport, parentReportActions, policy, transaction, file, sourceState]); // There are a few things that shouldn't be set until we absolutely know if the file is a receipt or an attachment. // props.isReceiptAttachment will be null until its certain what the file is, in which case it will then be true|false. - let headerTitle = props.headerTitle; + let headerTitleValue = headerTitle; let shouldShowDownloadButton = false; let shouldShowThreeDotsButton = false; - if (!_.isEmpty(props.report)) { - headerTitle = translate(props.isReceiptAttachment ? 'common.receipt' : 'common.attachment'); - shouldShowDownloadButton = props.allowDownload && isDownloadButtonReadyToBeShown && !props.isReceiptAttachment && !isOffline; - shouldShowThreeDotsButton = props.isReceiptAttachment && isModalOpen; + if (isNotEmptyObject(report)) { + headerTitleValue = translate(isReceiptAttachment ? 'common.receipt' : 'common.attachment'); + shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !isReceiptAttachment && !isOffline; + shouldShowThreeDotsButton = isReceiptAttachment && isModalOpen; } return ( @@ -426,24 +410,24 @@ function AttachmentModal(props) { isVisible={isModalOpen} backgroundColor={theme.componentBG} onModalShow={() => { - props.onModalShow(); + onModalShow(); setShouldLoadAttachment(true); }} onModalHide={(e) => { - props.onModalHide(e); + onModalHide(e); setShouldLoadAttachment(false); }} propagateSwipe > - {props.isSmallScreenWidth && } + {isSmallScreenWidth && } downloadAttachment(source)} - shouldShowCloseButton={!props.isSmallScreenWidth} - shouldShowBackButton={props.isSmallScreenWidth} + onDownloadButtonPress={() => downloadAttachment(sourceState)} + shouldShowCloseButton={!isSmallScreenWidth} + shouldShowBackButton={isSmallScreenWidth} onBackButtonPress={closeModal} onCloseButtonPress={closeModal} shouldShowThreeDotsButton={shouldShowThreeDotsButton} @@ -452,11 +436,11 @@ function AttachmentModal(props) { shouldOverlay /> - {!_.isEmpty(props.report) && !props.isReceiptAttachment ? ( + {report && !isReceiptAttachment ? ( ) )} {/* If we have an onConfirm method show a confirmation button */} - {Boolean(props.onConfirm) && ( + {Boolean(onConfirm) && ( {({safeAreaPaddingBottomStyle}) => (