diff --git a/src/CONST.ts b/src/CONST.ts index 55beabb8e85e..709e9d3bafe2 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2880,6 +2880,8 @@ const CONST = { */ ADDITIONAL_ALLOWED_CHARACTERS: 20, + /** types that will show a virtual keyboard in a mobile browser */ + INPUT_TYPES_WITH_KEYBOARD: ['text', 'search', 'tel', 'url', 'email', 'password'], /** * native IDs for close buttons in Overlay component */ diff --git a/src/components/SwipeableView/index.native.tsx b/src/components/SwipeableView/index.native.tsx index 91d851101d4e..07f1d785d0a6 100644 --- a/src/components/SwipeableView/index.native.tsx +++ b/src/components/SwipeableView/index.native.tsx @@ -3,30 +3,40 @@ import {PanResponder, View} from 'react-native'; import CONST from '@src/CONST'; import SwipeableViewProps from './types'; -function SwipeableView({children, onSwipeDown}: SwipeableViewProps) { +function SwipeableView({children, onSwipeDown, onSwipeUp}: SwipeableViewProps) { const minimumPixelDistance = CONST.COMPOSER_MAX_HEIGHT; const oldYRef = useRef(0); + const directionRef = useRef<'UP' | 'DOWN' | null>(null); + const panResponder = useRef( PanResponder.create({ - // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance & swipe direction is downwards - // eslint-disable-next-line @typescript-eslint/naming-convention - onMoveShouldSetPanResponderCapture: (_event, gestureState) => { + onMoveShouldSetPanResponderCapture: (event, gestureState) => { if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) { + directionRef.current = 'DOWN'; + return true; + } + + if (gestureState.dy - oldYRef.current < 0 && Math.abs(gestureState.dy) > minimumPixelDistance) { + directionRef.current = 'UP'; return true; } oldYRef.current = gestureState.dy; return false; }, - // Calls the callback when the swipe down is released; after the completion of the gesture - onPanResponderRelease: onSwipeDown, + onPanResponderRelease: () => { + if (directionRef.current === 'DOWN' && onSwipeDown) { + onSwipeDown(); + } else if (directionRef.current === 'UP' && onSwipeUp) { + onSwipeUp(); + } + directionRef.current = null; // Reset the direction after the gesture completes + }, }), ).current; - return ( - // eslint-disable-next-line react/jsx-props-no-spreading - {children} - ); + // eslint-disable-next-line react/jsx-props-no-spreading + return {children}; } SwipeableView.displayName = 'SwipeableView'; diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx index 335c3e7dcf03..478935173841 100644 --- a/src/components/SwipeableView/index.tsx +++ b/src/components/SwipeableView/index.tsx @@ -1,4 +1,77 @@ +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; +import DomUtils from '@libs/DomUtils'; import SwipeableViewProps from './types'; -// Swipeable View is available just on Android/iOS for now. -export default ({children}: SwipeableViewProps) => children; +// Min delta y in px to trigger swipe +const MIN_DELTA_Y = 25; + +function SwipeableView({onSwipeUp, onSwipeDown, style, children}: SwipeableViewProps) { + const ref = useRef(null); + const scrollableChildRef = useRef(null); + const startY = useRef(0); + const isScrolling = useRef(false); + + useEffect(() => { + if (!ref.current) { + return; + } + + const element = ref.current as unknown as HTMLElement; + + const handleTouchStart = (event: TouchEvent) => { + startY.current = event.touches[0].clientY; + }; + + const handleTouchEnd = (event: TouchEvent) => { + const deltaY = event.changedTouches[0].clientY - startY.current; + const isSelecting = DomUtils.isActiveTextSelection(); + let canSwipeDown = true; + let canSwipeUp = true; + if (scrollableChildRef.current) { + canSwipeUp = scrollableChildRef.current.scrollHeight - scrollableChildRef.current.scrollTop === scrollableChildRef.current.clientHeight; + canSwipeDown = scrollableChildRef.current.scrollTop === 0; + } + + if (deltaY > MIN_DELTA_Y && onSwipeDown && !isSelecting && canSwipeDown && !isScrolling.current) { + onSwipeDown(); + } + + if (deltaY < -MIN_DELTA_Y && onSwipeUp && !isSelecting && canSwipeUp && !isScrolling.current) { + onSwipeUp(); + } + isScrolling.current = false; + }; + + const handleScroll = (event: Event) => { + isScrolling.current = true; + if (!event.target || scrollableChildRef.current) { + return; + } + scrollableChildRef.current = event.target as HTMLElement; + }; + + element.addEventListener('touchstart', handleTouchStart); + element.addEventListener('touchend', handleTouchEnd); + element.addEventListener('scroll', handleScroll, true); + + return () => { + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchend', handleTouchEnd); + element.removeEventListener('scroll', handleScroll); + }; + }, [onSwipeDown, onSwipeUp]); + + return ( + + {children} + + ); +} + +SwipeableView.displayName = 'SwipeableView'; + +export default SwipeableView; diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts index 560df7ef5a45..1f2fbcdc752c 100644 --- a/src/components/SwipeableView/types.ts +++ b/src/components/SwipeableView/types.ts @@ -1,11 +1,18 @@ import {ReactNode} from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; type SwipeableViewProps = { /** The content to be rendered within the SwipeableView */ children: ReactNode; /** Callback to fire when the user swipes down on the child content */ - onSwipeDown: () => void; + onSwipeDown?: () => void; + + /** Callback to fire when the user swipes up on the child content */ + onSwipeUp?: () => void; + + /** Style for the wrapper View, applied only for the web version. Not used by the native version, as it brakes the layout. */ + style?: StyleProp; }; export default SwipeableViewProps; diff --git a/src/hooks/useBlockViewportScroll/index.native.ts b/src/hooks/useBlockViewportScroll/index.native.ts new file mode 100644 index 000000000000..59ee34b1c9f6 --- /dev/null +++ b/src/hooks/useBlockViewportScroll/index.native.ts @@ -0,0 +1,15 @@ +/** + * A hook that blocks viewport scroll when the keyboard is visible. + * It does this by capturing the current scrollY position when the keyboard is shown, then scrolls back to this position smoothly on 'touchend' event. + * This scroll blocking is removed when the keyboard hides. + * This hook is doing nothing on native platforms. + * + * @example + * useBlockViewportScroll(); + */ +function useBlockViewportScroll() { + // This hook is doing nothing on native platforms. + // Check index.ts for web implementation. +} + +export default useBlockViewportScroll; diff --git a/src/hooks/useBlockViewportScroll/index.ts b/src/hooks/useBlockViewportScroll/index.ts new file mode 100644 index 000000000000..5766d59f2bdd --- /dev/null +++ b/src/hooks/useBlockViewportScroll/index.ts @@ -0,0 +1,43 @@ +import {useEffect, useRef} from 'react'; +import Keyboard from '@libs/NativeWebKeyboard'; + +/** + * A hook that blocks viewport scroll when the keyboard is visible. + * It does this by capturing the current scrollY position when the keyboard is shown, then scrolls back to this position smoothly on 'touchend' event. + * This scroll blocking is removed when the keyboard hides. + * This hook is doing nothing on native platforms. + * + * @example + * useBlockViewportScroll(); + */ +function useBlockViewportScroll() { + const optimalScrollY = useRef(0); + const keyboardShowListenerRef = useRef(() => {}); + const keyboardHideListenerRef = useRef(() => {}); + + useEffect(() => { + const handleTouchEnd = () => { + window.scrollTo({top: optimalScrollY.current, behavior: 'smooth'}); + }; + + const handleKeybShow = () => { + optimalScrollY.current = window.scrollY; + window.addEventListener('touchend', handleTouchEnd); + }; + + const handleKeybHide = () => { + window.removeEventListener('touchend', handleTouchEnd); + }; + + keyboardShowListenerRef.current = Keyboard.addListener('keyboardDidShow', handleKeybShow); + keyboardHideListenerRef.current = Keyboard.addListener('keyboardDidHide', handleKeybHide); + + return () => { + keyboardShowListenerRef.current(); + keyboardHideListenerRef.current(); + window.removeEventListener('touchend', handleTouchEnd); + }; + }, []); +} + +export default useBlockViewportScroll; diff --git a/src/libs/DomUtils/index.native.ts b/src/libs/DomUtils/index.native.ts index 0864f1a16ac0..8af83968e8d1 100644 --- a/src/libs/DomUtils/index.native.ts +++ b/src/libs/DomUtils/index.native.ts @@ -2,6 +2,21 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => null; +/** + * Checks if there is a text selection within the currently focused input or textarea element. + * + * This function determines whether the currently focused element is an input or textarea, + * and if so, it checks whether there is a text selection (i.e., whether the start and end + * of the selection are at different positions). It assumes that only inputs and textareas + * can have text selections. + * Works only on web. Throws an error on native. + * + * @returns True if there is a text selection within the focused element, false otherwise. + */ +const isActiveTextSelection = () => { + throw new Error('Not implemented in React Native. Use only for web.'); +}; + const requestAnimationFrame = (callback: () => void) => { if (!callback) { return; @@ -12,5 +27,6 @@ const requestAnimationFrame = (callback: () => void) => { export default { getActiveElement, + isActiveTextSelection, requestAnimationFrame, }; diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index 6a2eed57fbe6..78c2cb37ccc8 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -2,7 +2,30 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => document.activeElement; +/** + * Checks if there is a text selection within the currently focused input or textarea element. + * + * This function determines whether the currently focused element is an input or textarea, + * and if so, it checks whether there is a text selection (i.e., whether the start and end + * of the selection are at different positions). It assumes that only inputs and textareas + * can have text selections. + * Works only on web. Throws an error on native. + * + * @returns True if there is a text selection within the focused element, false otherwise. + */ +const isActiveTextSelection = (): boolean => { + const focused = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null; + if (!focused) { + return false; + } + if (typeof focused.selectionStart === 'number' && typeof focused.selectionEnd === 'number') { + return focused.selectionStart !== focused.selectionEnd; + } + return false; +}; + export default { getActiveElement, + isActiveTextSelection, requestAnimationFrame: window.requestAnimationFrame.bind(window), }; diff --git a/src/libs/NativeWebKeyboard/index.native.ts b/src/libs/NativeWebKeyboard/index.native.ts new file mode 100644 index 000000000000..404bd58075d4 --- /dev/null +++ b/src/libs/NativeWebKeyboard/index.native.ts @@ -0,0 +1,3 @@ +import {Keyboard} from 'react-native'; + +export default Keyboard; diff --git a/src/libs/NativeWebKeyboard/index.ts b/src/libs/NativeWebKeyboard/index.ts new file mode 100644 index 000000000000..45223d4d5b42 --- /dev/null +++ b/src/libs/NativeWebKeyboard/index.ts @@ -0,0 +1,136 @@ +import {Keyboard} from 'react-native'; +import CONST from '@src/CONST'; + +type InputType = (typeof CONST.INPUT_TYPES_WITH_KEYBOARD)[number]; +type TCallbackFn = () => void; + +const isInputKeyboardType = (element: Element | null): boolean => { + if (element && ((element.tagName === 'INPUT' && CONST.INPUT_TYPES_WITH_KEYBOARD.includes((element as HTMLInputElement).type as InputType)) || element.tagName === 'TEXTAREA')) { + return true; + } + return false; +}; + +const isVisible = (): boolean => { + const focused = document.activeElement; + return isInputKeyboardType(focused); +}; + +const nullFn: () => null = () => null; + +let isKeyboardListenerRunning = false; +let currentVisibleElement: Element | null = null; +const showListeners: TCallbackFn[] = []; +const hideListeners: TCallbackFn[] = []; +const visualViewport = window.visualViewport ?? { + height: window.innerHeight, + width: window.innerWidth, + addEventListener: window.addEventListener.bind(window), + removeEventListener: window.removeEventListener.bind(window), +}; +let previousVPHeight = visualViewport.height; + +const handleViewportResize = (): void => { + if (visualViewport.height < previousVPHeight) { + if (isInputKeyboardType(document.activeElement) && document.activeElement !== currentVisibleElement) { + showListeners.forEach((fn) => fn()); + } + } + + if (visualViewport.height > previousVPHeight) { + if (!isVisible()) { + hideListeners.forEach((fn) => fn()); + } + } + + previousVPHeight = visualViewport.height; + currentVisibleElement = document.activeElement; +}; + +const startKeboardListeningService = (): void => { + isKeyboardListenerRunning = true; + visualViewport.addEventListener('resize', handleViewportResize); +}; + +const addListener = (eventName: 'keyboardDidShow' | 'keyboardDidHide', callbackFn: TCallbackFn): (() => void) => { + if ((eventName !== 'keyboardDidShow' && eventName !== 'keyboardDidHide') || !callbackFn) { + throw new Error('Invalid eventName passed to addListener()'); + } + + if (eventName === 'keyboardDidShow') { + showListeners.push(callbackFn); + } + + if (eventName === 'keyboardDidHide') { + hideListeners.push(callbackFn); + } + + if (!isKeyboardListenerRunning) { + startKeboardListeningService(); + } + + return () => { + if (eventName === 'keyboardDidShow') { + showListeners.filter((fn) => fn !== callbackFn); + } + + if (eventName === 'keyboardDidHide') { + hideListeners.filter((fn) => fn !== callbackFn); + } + + if (isKeyboardListenerRunning && !showListeners.length && !hideListeners.length) { + visualViewport.removeEventListener('resize', handleViewportResize); + isKeyboardListenerRunning = false; + } + }; +}; + +export default { + /** + * Whether the keyboard is last known to be visible. + */ + isVisible, + /** + * Dismisses the active keyboard and removes focus. + */ + dismiss: Keyboard.dismiss, + /** + * The `addListener` function connects a JavaScript function to an identified native + * keyboard notification event. + * + * This function then returns the reference to the listener. + * + * {string} eventName The `nativeEvent` is the string that identifies the event you're listening for. This + * can be any of the following: + * + * - `keyboardWillShow` + * - `keyboardDidShow` + * - `keyboardWillHide` + * - `keyboardDidHide` + * - `keyboardWillChangeFrame` + * - `keyboardDidChangeFrame` + * + * Note that if you set `android:windowSoftInputMode` to `adjustResize` or `adjustNothing`, + * only `keyboardDidShow` and `keyboardDidHide` events will be available on Android. + * `keyboardWillShow` as well as `keyboardWillHide` are generally not available on Android + * since there is no native corresponding event. + * + * On Web only two events are available: + * + * - `keyboardDidShow` + * - `keyboardDidHide` + * + * {function} callback function to be called when the event fires. + */ + addListener, + /** + * Useful for syncing TextInput (or other keyboard accessory view) size of + * position changes with keyboard movements. + * Not working on web. + */ + scheduleLayoutAnimation: nullFn, + /** + * Return the metrics of the soft-keyboard if visible. Currently not working on web. + */ + metrics: nullFn, +}; diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 0f09b51487ae..3353f791745f 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -15,6 +15,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; import withCurrentReportID, {withCurrentReportIDDefaultProps, withCurrentReportIDPropTypes} from '@components/withCurrentReportID'; import withViewportOffsetTop from '@components/withViewportOffsetTop'; +import useBlockViewportScroll from '@hooks/useBlockViewportScroll'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -150,6 +151,7 @@ function ReportScreen({ const styles = useThemeStyles(); const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); + useBlockViewportScroll(); const firstRenderRef = useRef(true); const flatListRef = useRef(); diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index e5dd5da19ad5..9f973674d6a7 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -92,7 +92,10 @@ function ReportFooter(props) { )} {!hideComposer && (props.shouldShowComposeInput || !props.isSmallScreenWidth) && ( - +