Skip to content

Commit

Permalink
Merge pull request Expensify#21298 from lukemorawski/lukemorawski1964…
Browse files Browse the repository at this point in the history
…2-blank_area_on_scroll

Unnecessary blank area is created when scrolling down while keyboard is open Expensify#19642
  • Loading branch information
thienlnam authored Nov 19, 2023
2 parents 4df083c + 8896a4d commit 0c77cbc
Show file tree
Hide file tree
Showing 13 changed files with 359 additions and 26 deletions.
2 changes: 2 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2880,6 +2880,8 @@ const CONST = {
*/
ADDITIONAL_ALLOWED_CHARACTERS: 20,

/** <input /> 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
*/
Expand Down
30 changes: 20 additions & 10 deletions src/components/SwipeableView/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<View {...panResponder.panHandlers}>{children}</View>
);
// eslint-disable-next-line react/jsx-props-no-spreading
return <View {...panResponder.panHandlers}>{children}</View>;
}

SwipeableView.displayName = 'SwipeableView';
Expand Down
77 changes: 75 additions & 2 deletions src/components/SwipeableView/index.tsx
Original file line number Diff line number Diff line change
@@ -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<View | null>(null);
const scrollableChildRef = useRef<HTMLElement | null>(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 (
<View
ref={ref}
style={style}
>
{children}
</View>
);
}

SwipeableView.displayName = 'SwipeableView';

export default SwipeableView;
9 changes: 8 additions & 1 deletion src/components/SwipeableView/types.ts
Original file line number Diff line number Diff line change
@@ -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<ViewStyle>;
};

export default SwipeableViewProps;
15 changes: 15 additions & 0 deletions src/hooks/useBlockViewportScroll/index.native.ts
Original file line number Diff line number Diff line change
@@ -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;
43 changes: 43 additions & 0 deletions src/hooks/useBlockViewportScroll/index.ts
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 16 additions & 0 deletions src/libs/DomUtils/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,5 +27,6 @@ const requestAnimationFrame = (callback: () => void) => {

export default {
getActiveElement,
isActiveTextSelection,
requestAnimationFrame,
};
23 changes: 23 additions & 0 deletions src/libs/DomUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
3 changes: 3 additions & 0 deletions src/libs/NativeWebKeyboard/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {Keyboard} from 'react-native';

export default Keyboard;
Loading

0 comments on commit 0c77cbc

Please sign in to comment.