fix: keep the event editor's bottom fields above the keyboard #81

This commit is contained in:
Linus Rath
2026-10-29 03:44:43 +01:00
parent 7a1031a990
commit 01983a3810
3 changed files with 55 additions and 3 deletions
+26 -3
View File
@@ -8,8 +8,9 @@ import {
StyleSheet,
Platform,
Alert as NativeAlert,
type LayoutChangeEvent,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import DateTimePicker from '@react-native-community/datetimepicker';
import {
X,
@@ -72,6 +73,8 @@ import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { SafeAreaModal } from '../SafeAreaModal';
import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction';
import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard';
import ToggleSwitch from '../ToggleSwitch';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -185,6 +188,25 @@ export function EventModal({
const isEdit = !!event;
const resolveContactName = useContactNameResolver();
// The keyboard covered the bottom fields (location, description; #81).
// iOS: the scroll view insets itself and scrolls the focused field into
// view (automaticallyAdjustKeyboardInsets). Android: the edge-to-edge
// dialog does not resize for the keyboard, so the scroll view ends above
// it, and Android's ScrollView brings the focused field back into view
// when it shrinks.
const insets = useSafeAreaInsets();
const keyboard = useKeyboardObstruction(insets.bottom);
const fullHeight = React.useRef(0);
const [containerHeight, setContainerHeight] = React.useState(0);
const onContainerLayout = (e: LayoutChangeEvent) => {
const { height } = e.nativeEvent.layout;
if (keyboard === 0) fullHeight.current = height;
setContainerHeight(height);
};
const keyboardInset = Platform.OS === 'android'
? editorKeyboardInset(keyboard, insets.bottom, fullHeight.current - containerHeight)
: 0;
const [title, setTitle] = React.useState('');
const [description, setDescription] = React.useState('');
const [allDay, setAllDay] = React.useState(false);
@@ -441,7 +463,7 @@ export function EventModal({
presentationStyle="fullScreen"
onRequestClose={onClose}
>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']} onLayout={onContainerLayout}>
<View style={styles.header}>
<Pressable
onPress={onClose}
@@ -467,9 +489,10 @@ export function EventModal({
</View>
<ScrollView
style={styles.body}
style={[styles.body, keyboardInset > 0 && { marginBottom: keyboardInset }]}
contentContainerStyle={styles.bodyContent}
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
>
<TextInput
value={title}
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { editorKeyboardInset } from '../calendar-editor-keyboard';
describe('editorKeyboardInset (#81)', () => {
it('ends the scroll area above the keyboard, past the safe-area inset already kept clear', () => {
expect(editorKeyboardInset(320, 24, 0)).toBe(296);
});
it('shortens a window that resized for the keyboard by nothing more', () => {
expect(editorKeyboardInset(320, 24, 296)).toBe(0);
expect(editorKeyboardInset(320, 24, 400)).toBe(0);
});
it('keeps the full height without a keyboard', () => {
expect(editorKeyboardInset(0, 24, 0)).toBe(0);
});
});
+12
View File
@@ -0,0 +1,12 @@
/**
* How far above its normal bottom the event editor's scroll area has to end
* so the keyboard covers none of it (#81). `obstruction` is how much of the
* screen bottom the keyboard covers (use-keyboard-obstruction),
* `bottomInset` the safe-area inset the editor already keeps clear, and
* `resizedBy` how much the window itself shrank for the keyboard: an
* edge-to-edge Android dialog does not resize at all, a window that does
* must not be shortened twice.
*/
export function editorKeyboardInset(obstruction: number, bottomInset: number, resizedBy: number): number {
return Math.max(0, Math.round(obstruction - bottomInset - Math.max(0, resizedBy)));
}