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) && (
-
+