fix: scroll the new tag form above the keyboard in settings

This commit is contained in:
Linus Rath
2026-10-31 20:04:47 +01:00
parent 62e4806353
commit d88d3dfbaf
4 changed files with 169 additions and 5 deletions
+6 -1
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { SettingsKeyboardContext } from './keyboard-reveal';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useKeywordsStore, currentTagsLogin, type KeywordDef, type KeywordVisibility } from '../../stores/keywords-store';
@@ -343,6 +344,9 @@ function KeywordForm({ initial, existingIds, keywords, nested, onSave, onCancel
...parentKeywordOptions(keywords).map((o) => ({ value: o.id, label: o.label })),
];
const initialParent = initial ? getParentKeywordId(initial.id) : null;
// The whole form goes above the keyboard, the parent picker included.
const revealOnKeyboard = React.useContext(SettingsKeyboardContext);
const formRef = React.useRef<View>(null);
const handleSave = () => {
if (!isValid) return;
@@ -350,7 +354,7 @@ function KeywordForm({ initial, existingIds, keywords, nested, onSave, onCancel
};
return (
<View style={styles.form}>
<View ref={formRef} style={styles.form}>
{nested && !initial && keywords.length > 0 && (
<View>
<Text style={styles.formLabel}>{t('settings.keywords.parent_field', 'Parent tag')}</Text>
@@ -368,6 +372,7 @@ function KeywordForm({ initial, existingIds, keywords, nested, onSave, onCancel
<TextInput accessibilityLabel={t('settings.keywords.label_field', "Display Name")}
value={label}
onChangeText={setLabel}
onFocus={() => revealOnKeyboard?.(formRef.current)}
placeholder={t('settings.keywords.label_placeholder', "e.g. Work, Personal, Urgent")}
placeholderTextColor={c.mutedForeground}
style={styles.input}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { revealScrollY, settingsPagePaddingBottom, visibleHeightAboveKeyboard } from '../keyboard-reveal';
// Settings > Tags > Add tag: the form at the end of the page stayed under the
// keyboard (the window does not shrink for it on Android edge to edge), so
// taps meant for "No parent" landed on the keys.
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', '..', rel), 'utf8');
describe('settings keyboard reveal', () => {
// A phone: page from y=80 to y=800, keyboard top at y=500.
const visible = visibleHeightAboveKeyboard(80, 720, 500);
it('measures the page height left above the keyboard', () => {
expect(visible).toBe(420);
expect(visibleHeightAboveKeyboard(80, 720, 900)).toBe(720);
expect(visibleHeightAboveKeyboard(80, 720, 60)).toBe(0);
});
it('scrolls a form under the keyboard up until its end clears it', () => {
// Form at page y 600..800 with the page at the top: its end is hidden.
expect(revealScrollY(0, 600, 800, visible, 12)).toBe(392);
});
it('keeps the top of a form taller than the space above the keyboard', () => {
expect(revealScrollY(0, 600, 1200, visible, 12)).toBe(588);
});
it('leaves a form that already fits alone', () => {
expect(revealScrollY(0, 100, 300, visible, 12)).toBeNull();
});
it('pads the page end by the keyboard so it can scroll that far', () => {
expect(settingsPagePaddingBottom(40, 0)).toBe(40);
expect(settingsPagePaddingBottom(40, 300)).toBe(340);
});
it('pads the settings page and hands the tag form to it on focus', () => {
const screen = read('screens/SettingsScreen.tsx');
expect(screen).toMatch(/paddingBottom: settingsPagePaddingBottom\(DETAIL_PADDING_BOTTOM, keyboard\)/);
expect(screen).toMatch(/<SettingsKeyboardContext\.Provider value=\{revealOnKeyboard\}>/);
const tags = read('components/settings/KeywordSettings.tsx');
expect(tags).toMatch(/<View ref=\{formRef\} style=\{styles\.form\}>/);
expect(tags).toMatch(/onFocus=\{\(\) => revealOnKeyboard\?\.\(formRef\.current\)\}/);
});
});
@@ -0,0 +1,55 @@
import { createContext } from 'react';
import type { View } from 'react-native';
/**
* Keeps a settings form in view while the keyboard is up. Android draws the
* app edge to edge, so the window does not shrink for the keyboard: a form at
* the bottom of a settings page (Settings > Tags > Add tag) stayed under the
* keyboard, and taps meant for its fields landed on the keys. The page pads
* its bottom by the keyboard and scrolls the form the user is typing in above
* it.
*/
/**
* Hand a form's outer View to the settings page when one of its fields gets
* focus; the page scrolls the whole form above the keyboard. Without a form,
* the page scrolls the focused field into view.
*/
export const SettingsKeyboardContext = createContext<((form: View | null) => void) | null>(null);
/** Space kept between the form and the keyboard or the page top. */
export const REVEAL_MARGIN = 12;
/**
* The height of the scroll area still visible above the keyboard.
* `viewportTop` and `keyboardTop` are window coordinates.
*/
export function visibleHeightAboveKeyboard(viewportTop: number, viewportHeight: number, keyboardTop: number): number {
return Math.max(0, Math.min(viewportHeight, keyboardTop - viewportTop));
}
/**
* Where to scroll so the form (content coordinates `formTop`..`formBottom`)
* fits in the `visibleHeight` above the keyboard, or null when it already
* does. A form taller than that keeps its top in view.
*/
export function revealScrollY(
scrollY: number,
formTop: number,
formBottom: number,
visibleHeight: number,
margin: number = REVEAL_MARGIN,
): number | null {
if (visibleHeight <= 0) return null;
const keepTop = formTop - margin;
let target = scrollY;
if (formBottom + margin > scrollY + visibleHeight) target = Math.min(formBottom + margin - visibleHeight, keepTop);
else if (keepTop < scrollY) target = keepTop;
target = Math.max(0, Math.round(target));
return target === Math.round(scrollY) ? null : target;
}
/** Bottom padding of a settings page: room to scroll its end above the keyboard. */
export function settingsPagePaddingBottom(base: number, keyboard: number): number {
return base + Math.max(0, Math.round(keyboard));
}
+60 -4
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable, TextInput, Keyboard } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { View, Text, StyleSheet, ScrollView, Pressable, TextInput, Keyboard, Dimensions } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
import { useLayout } from '../lib/use-layout';
import { confirmSignOut } from '../lib/sign-out-confirm';
import {
@@ -71,11 +72,16 @@ import {
type SettingsTabId, type SubResult,
} from '../lib/settings-search';
import { SearchHighlightContext, type SearchHighlight } from '../components/settings/search-highlight';
import {
SettingsKeyboardContext, revealScrollY, settingsPagePaddingBottom, visibleHeightAboveKeyboard,
} from '../components/settings/keyboard-reveal';
import { BUILTIN_THEMES } from '../theme/builtin-themes';
import { getDictionary } from '../i18n';
type Tab = SettingsTabId;
const DETAIL_PADDING_BOTTOM = 40;
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
interface TabDef {
@@ -391,6 +397,46 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
}
setPendingHighlight(null);
}, []);
// The window does not shrink for the keyboard (Android edge to edge, iOS):
// the page pads its end by the keyboard and scrolls the form being typed
// in above it (Settings > Tags > Add tag sat under the keyboard).
const insets = useSafeAreaInsets();
const keyboard = useKeyboardObstruction(insets.bottom);
const keyboardRef = React.useRef(keyboard);
keyboardRef.current = keyboard;
const paneScrollY = React.useRef(0);
const revealForm = React.useRef<View | null>(null);
const revealFocused = React.useCallback(() => {
if (keyboardRef.current <= 0) return;
const scroll = paneScrollRef.current;
const viewport = scroll?.getNativeScrollRef();
const target = revealForm.current ?? (TextInput.State.currentlyFocusedInput() as unknown as View | null);
if (!scroll || !viewport || !target) return;
viewport.measureInWindow((_vx, viewportTop, _vw, viewportHeight) => {
target.measureInWindow((_x, top, _w, height) => {
const keyboardTop = Dimensions.get('screen').height - keyboardRef.current;
const visible = visibleHeightAboveKeyboard(viewportTop, viewportHeight, keyboardTop);
// Window coordinates turned into the page's own.
const formTop = top - viewportTop + paneScrollY.current;
const y = revealScrollY(paneScrollY.current, formTop, formTop + height, visible);
if (y !== null) scroll.scrollTo({ y, animated: true });
});
});
}, []);
const revealOnKeyboard = React.useCallback((form: View | null) => {
revealForm.current = form;
revealFocused();
}, [revealFocused]);
useEffect(() => {
if (keyboard <= 0) {
revealForm.current = null;
return undefined;
}
// After the keyboard padding is laid out, so the page can scroll that far.
const frame = requestAnimationFrame(revealFocused);
return () => cancelAnimationFrame(frame);
}, [keyboard, revealFocused]);
const searchHighlight = React.useMemo<SearchHighlight | null>(
() => (pendingHighlight && pendingHighlight.tab === selectedTab
? { label: pendingHighlight.label, reveal: revealHighlight }
@@ -456,8 +502,17 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
{/* Keyed per page: React would otherwise reuse the list's ScrollView
and open the page at the list's scroll offset. */}
<ScrollView key={`page-${selectedTab}`} ref={paneScrollRef} style={styles.scrollArea} contentContainerStyle={styles.detailContent} keyboardShouldPersistTaps="handled">
<ScrollView
key={`page-${selectedTab}`}
ref={paneScrollRef}
style={styles.scrollArea}
contentContainerStyle={[styles.detailContent, { paddingBottom: settingsPagePaddingBottom(DETAIL_PADDING_BOTTOM, keyboard) }]}
keyboardShouldPersistTaps="handled"
onScroll={(e) => { paneScrollY.current = e.nativeEvent.contentOffset.y; }}
scrollEventThrottle={64}
>
<View ref={paneContentRef}>
<SettingsKeyboardContext.Provider value={revealOnKeyboard}>
<SearchHighlightContext.Provider value={searchHighlight}>
{scopeBanner}
{selectedTab === 'filters' ? (
@@ -470,6 +525,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
<StorageSettings onOpenTroubleshooting={() => openPage('debug')} />
) : Component ? <Component /> : null}
</SearchHighlightContext.Provider>
</SettingsKeyboardContext.Provider>
</View>
</ScrollView>
</SafeAreaView>
@@ -662,7 +718,7 @@ function makeStyles(c: ThemePalette) {
scrollContent: { paddingTop: spacing.md, paddingBottom: 40 },
// A page keeps form width on a wide pane: labels and their controls
// 700 dp apart are hard to read across.
detailContent: { padding: spacing.lg, paddingBottom: 40, width: '100%', maxWidth: 720, alignSelf: 'center' },
detailContent: { padding: spacing.lg, paddingBottom: DETAIL_PADDING_BOTTOM, width: '100%', maxWidth: 720, alignSelf: 'center' },
accountCard: {
flexDirection: 'row',