Skip to content

Commit ff4bd58

Browse files
aliyilmaztechclaude
andcommitted
fix(modal): move dialogs above the on-screen keyboard
Components rendered in a `Portal`, such as `Modal` and `Dialog`, used to be kept above the keyboard by the system: on Android `windowSoftInputMode` was set to `adjustResize`, which shrank the whole window. That no longer happens in edge-to-edge mode, which is enforced since Android 15, where the keyboard is reported as an inset that has to be handled by the app, and it never happened on iOS. Track the keyboard in `Modal` and pad the wrapper by the distance between its measured bottom edge and the top edge of the keyboard, so the content stays centered in the visible area. Both edges are relative to the window, so windows which are still resized by the system need no special casing: their wrapper is already laid out above the keyboard, which puts the overlap at or below zero. Fixes #5021 Fixes #4218 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1b2b043 commit ff4bd58

6 files changed

Lines changed: 360 additions & 4 deletions

File tree

example/src/Examples/DialogExample.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DialogWithLoadingIndicator,
1111
DialogWithLongText,
1212
DialogWithRadioBtns,
13+
DialogWithTextInput,
1314
UndismissableDialog,
1415
} from './Dialogs';
1516
import ScreenWrapper from '../ScreenWrapper';
@@ -70,6 +71,13 @@ const DialogExample = () => {
7071
>
7172
With icon
7273
</Button>
74+
<Button
75+
mode="outlined"
76+
onPress={_toggleDialog('dialog8')}
77+
style={styles.button}
78+
>
79+
With text input
80+
</Button>
7381
{Platform.OS === 'android' && (
7482
<Button
7583
mode="outlined"
@@ -107,6 +115,10 @@ const DialogExample = () => {
107115
visible={_getVisible('dialog7')}
108116
close={_toggleDialog('dialog7')}
109117
/>
118+
<DialogWithTextInput
119+
visible={_getVisible('dialog8')}
120+
close={_toggleDialog('dialog8')}
121+
/>
110122
</ScreenWrapper>
111123
);
112124
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import * as React from 'react';
2+
import { StyleSheet } from 'react-native';
3+
4+
import { Button, Portal, Dialog, TextInput } from 'react-native-paper';
5+
6+
import { TextComponent } from './DialogTextComponent';
7+
8+
const DialogWithTextInput = ({
9+
visible,
10+
close,
11+
}: {
12+
visible: boolean;
13+
close: () => void;
14+
}) => {
15+
const [name, setName] = React.useState('');
16+
17+
return (
18+
<Portal>
19+
<Dialog onDismiss={close} visible={visible}>
20+
<Dialog.Title>Dialog with text input</Dialog.Title>
21+
<Dialog.Content>
22+
<TextComponent>
23+
Focus the input below to check that the dialog stays above the
24+
on-screen keyboard.
25+
</TextComponent>
26+
<TextInput
27+
label="Name"
28+
value={name}
29+
onChangeText={setName}
30+
style={styles.input}
31+
/>
32+
</Dialog.Content>
33+
<Dialog.Actions>
34+
<Button onPress={close}>Cancel</Button>
35+
<Button onPress={close}>Save</Button>
36+
</Dialog.Actions>
37+
</Dialog>
38+
</Portal>
39+
);
40+
};
41+
42+
const styles = StyleSheet.create({
43+
input: {
44+
marginTop: 16,
45+
},
46+
});
47+
48+
export default DialogWithTextInput;

example/src/Examples/Dialogs/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { default as DialogWithRadioBtns } from './DialogWithRadioBtns';
55
export { default as UndismissableDialog } from './UndismissableDialog';
66
export { default as DialogWithIcon } from './DialogWithIcon';
77
export { default as DialogWithDismissableBackButton } from './DialogWithDismissableBackButton';
8+
export { default as DialogWithTextInput } from './DialogWithTextInput';

src/components/Modal.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import { Animated, Easing, StyleSheet, Pressable, View } from 'react-native';
3-
import type { StyleProp, ViewStyle } from 'react-native';
3+
import type { LayoutChangeEvent, StyleProp, ViewStyle } from 'react-native';
44

55
import { useSafeAreaInsets } from 'react-native-safe-area-context';
66
import useLatestCallback from 'use-latest-callback';
@@ -12,6 +12,7 @@ import type { ThemeProp } from '../types';
1212
import { addEventListener } from '../utils/addEventListener';
1313
import { BackHandler } from '../utils/BackHandler/BackHandler';
1414
import useAnimatedValue from '../utils/useAnimatedValue';
15+
import useKeyboardOverlap from '../utils/useKeyboardOverlap';
1516

1617
const scrimAlpha = tokens.md.sys.scrim.alpha;
1718

@@ -114,6 +115,18 @@ function Modal({
114115
const { top, bottom } = useSafeAreaInsets();
115116
const opacity = useAnimatedValue(visible ? 1 : 0);
116117
const [visibleInternal, setVisibleInternal] = React.useState(visible);
118+
const [wrapperBottom, setWrapperBottom] = React.useState<number | null>(null);
119+
120+
const keyboardOverlap = useKeyboardOverlap({
121+
enabled: visibleInternal,
122+
containerBottom: wrapperBottom,
123+
});
124+
125+
const onWrapperLayout = React.useCallback((event: LayoutChangeEvent) => {
126+
const { y, height } = event.nativeEvent.layout;
127+
128+
setWrapperBottom(y + height);
129+
}, []);
117130

118131
const showModalAnimation = React.useCallback(() => {
119132
Animated.timing(opacity, {
@@ -209,9 +222,16 @@ function Modal({
209222
<View
210223
style={[
211224
styles.wrapper,
212-
{ marginTop: top, marginBottom: bottom },
225+
{
226+
marginTop: top,
227+
marginBottom: bottom,
228+
// Keeps the content above the on-screen keyboard on platforms where
229+
// the system doesn't resize the window, e.g. iOS or edge-to-edge Android.
230+
paddingBottom: keyboardOverlap,
231+
},
213232
style,
214233
]}
234+
onLayout={onWrapperLayout}
215235
pointerEvents="box-none"
216236
testID={`${testID}-wrapper`}
217237
>

src/components/__tests__/Modal.test.tsx

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import { Animated, BackHandler as RNBackHandler, Text } from 'react-native';
1+
import {
2+
Animated,
3+
BackHandler as RNBackHandler,
4+
DeviceEventEmitter,
5+
Dimensions,
6+
Platform,
7+
Text,
8+
} from 'react-native';
29
import type { BackHandlerStatic as RNBackHandlerStatic } from 'react-native';
310

411
import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals';
5-
import { act, userEvent } from '@testing-library/react-native';
12+
import { act, fireEvent, userEvent } from '@testing-library/react-native';
613

714
import { render, screen } from '../../test-utils';
815
import { LightTheme } from '../../theme/schemes';
@@ -575,6 +582,186 @@ describe('Modal', () => {
575582
});
576583
});
577584

585+
// `keyboardWillShow` and `keyboardWillHide` are not emitted on Android
586+
const keyboardEventsPerPlatform: Array<[typeof Platform.OS, string, string]> =
587+
[
588+
['ios', 'keyboardWillShow', 'keyboardWillHide'],
589+
['android', 'keyboardDidShow', 'keyboardDidHide'],
590+
];
591+
592+
describe.each(keyboardEventsPerPlatform)(
593+
'when the on-screen keyboard is shown on %s',
594+
(platform, showEvent, hideEvent) => {
595+
const KEYBOARD_HEIGHT = 300;
596+
const TOP_INSET = 37;
597+
const BOTTOM_INSET = 44;
598+
599+
const screenHeight = Dimensions.get('screen').height;
600+
// Space taken by the keyboard below the bottom edge of the wrapper
601+
const expectedOverlap = KEYBOARD_HEIGHT - BOTTOM_INSET;
602+
const originalPlatform = Platform.OS;
603+
604+
beforeAll(() => {
605+
Platform.OS = platform;
606+
});
607+
608+
afterAll(() => {
609+
Platform.OS = originalPlatform;
610+
});
611+
612+
const showKeyboard = async () => {
613+
await act(() => {
614+
DeviceEventEmitter.emit(showEvent, {
615+
endCoordinates: {
616+
screenX: 0,
617+
screenY: screenHeight - KEYBOARD_HEIGHT,
618+
width: 750,
619+
height: KEYBOARD_HEIGHT,
620+
},
621+
});
622+
});
623+
};
624+
625+
const hideKeyboard = async () => {
626+
await act(() => {
627+
DeviceEventEmitter.emit(hideEvent, {
628+
endCoordinates: {
629+
screenX: 0,
630+
screenY: screenHeight,
631+
width: 750,
632+
height: 0,
633+
},
634+
});
635+
});
636+
};
637+
638+
const layoutWrapper = async (y: number, height: number) => {
639+
await act(() =>
640+
fireEvent(screen.getByTestId('modal-wrapper'), 'layout', {
641+
nativeEvent: { layout: { x: 0, y, width: 750, height } },
642+
})
643+
);
644+
};
645+
646+
it('should keep the content above the keyboard if the window is not resized', async () => {
647+
await render(
648+
<Modal visible={true} testID="modal">
649+
{null}
650+
</Modal>
651+
);
652+
653+
expect(screen.getByTestId('modal-wrapper')).toHaveStyle({
654+
paddingBottom: 0,
655+
});
656+
657+
await layoutWrapper(TOP_INSET, screenHeight - TOP_INSET - BOTTOM_INSET);
658+
await showKeyboard();
659+
660+
expect(screen.getByTestId('modal-wrapper')).toHaveStyle({
661+
paddingBottom: expectedOverlap,
662+
});
663+
});
664+
665+
it('should account for a keyboard which is already open when it becomes visible', async () => {
666+
// `Keyboard.metrics()` is populated from `keyboardDidShow` on every platform
667+
await act(() => {
668+
DeviceEventEmitter.emit('keyboardDidShow', {
669+
endCoordinates: {
670+
screenX: 0,
671+
screenY: screenHeight - KEYBOARD_HEIGHT,
672+
width: 750,
673+
height: KEYBOARD_HEIGHT,
674+
},
675+
});
676+
});
677+
678+
const { rerender } = await render(
679+
<Modal visible={false} testID="modal">
680+
{null}
681+
</Modal>
682+
);
683+
684+
await rerender(
685+
<Modal visible={true} testID="modal">
686+
{null}
687+
</Modal>
688+
);
689+
690+
await layoutWrapper(TOP_INSET, screenHeight - TOP_INSET - BOTTOM_INSET);
691+
692+
expect(screen.getByTestId('modal-wrapper')).toHaveStyle({
693+
paddingBottom: expectedOverlap,
694+
});
695+
696+
await act(() => {
697+
DeviceEventEmitter.emit('keyboardDidHide', {
698+
endCoordinates: {
699+
screenX: 0,
700+
screenY: screenHeight,
701+
width: 750,
702+
height: 0,
703+
},
704+
});
705+
});
706+
});
707+
708+
it('should not add any padding if the window is resized by the system', async () => {
709+
await render(
710+
<Modal visible={true} testID="modal">
711+
{null}
712+
</Modal>
713+
);
714+
715+
// The system shrunk the window, so the wrapper is already above the keyboard
716+
await layoutWrapper(
717+
TOP_INSET,
718+
screenHeight - TOP_INSET - BOTTOM_INSET - KEYBOARD_HEIGHT
719+
);
720+
await showKeyboard();
721+
722+
expect(screen.getByTestId('modal-wrapper')).toHaveStyle({
723+
paddingBottom: 0,
724+
});
725+
});
726+
727+
it('should follow the wrapper if the safe area insets are overridden', async () => {
728+
await render(
729+
<Modal visible={true} testID="modal" style={{ marginBottom: 0 }}>
730+
{null}
731+
</Modal>
732+
);
733+
734+
await layoutWrapper(TOP_INSET, screenHeight - TOP_INSET);
735+
await showKeyboard();
736+
737+
expect(screen.getByTestId('modal-wrapper')).toHaveStyle({
738+
paddingBottom: KEYBOARD_HEIGHT,
739+
});
740+
});
741+
742+
it('should restore the original padding once the keyboard is hidden', async () => {
743+
await render(
744+
<Modal visible={true} testID="modal">
745+
{null}
746+
</Modal>
747+
);
748+
749+
await layoutWrapper(TOP_INSET, screenHeight - TOP_INSET - BOTTOM_INSET);
750+
await showKeyboard();
751+
752+
expect(screen.getByTestId('modal-wrapper')).toHaveStyle({
753+
paddingBottom: expectedOverlap,
754+
});
755+
756+
await hideKeyboard();
757+
758+
expect(screen.getByTestId('modal-wrapper')).toHaveStyle({
759+
paddingBottom: 0,
760+
});
761+
});
762+
}
763+
);
764+
578765
it('animated value changes correctly', async () => {
579766
const value = new Animated.Value(1);
580767
await render(

0 commit comments

Comments
 (0)