fix: use react-native-keyboard-controller for reliable keyboard handling (#6)

Replace manual keyboard listeners and RN KeyboardAvoidingView with
react-native-keyboard-controller which handles edge-to-edge correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-03-01 17:18:45 -05:00
parent 360310e99f
commit f2fe141737
7 changed files with 56 additions and 73 deletions

View file

@ -1,8 +1,9 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import {
View, Text, Pressable, useColorScheme, TextInput, Alert,
Modal, KeyboardAvoidingView, Platform, ScrollView,
Modal, Platform, ScrollView,
} from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { useRouter } from 'expo-router';
import {
Plus, ChevronRight, Check, GripVertical,
@ -249,8 +250,8 @@ export default function ListsScreen() {
{/* Create/Edit Modal */}
<Modal visible={showModal} transparent animationType="fade">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
behavior="padding"
style={{ flex: 1 }}
>
<Pressable onPress={() => setShowModal(false)} className="flex-1 justify-center bg-black/40 px-6">
<Pressable

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, KeyboardAvoidingView, Platform, Switch, Linking, ActivityIndicator } from 'react-native';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, Platform, Switch, Linking, ActivityIndicator } from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { useTranslation } from 'react-i18next';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, Mail, RefreshCw } from 'lucide-react-native';
import Constants from 'expo-constants';
@ -352,8 +353,8 @@ export default function SettingsScreen() {
{/* Tag Create/Edit Modal */}
<Modal visible={showTagModal} transparent animationType="fade">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
behavior="padding"
style={{ flex: 1 }}
>
<Pressable onPress={() => setShowTagModal(false)} className="flex-1 justify-center bg-black/40 px-6">
<Pressable

View file

@ -6,6 +6,7 @@ import { useFonts, Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_7
import * as SplashScreen from 'expo-splash-screen';
import { useMigrations } from 'drizzle-orm/expo-sqlite/migrator';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { db } from '@/src/db/client';
import migrations from '@/src/db/migrations/migrations';
@ -79,23 +80,25 @@ export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider value={effectiveScheme === 'dark' ? SimplDarkTheme : SimplLightTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="task/new"
options={{ presentation: 'modal', headerShown: false }}
/>
<Stack.Screen
name="task/[id]"
options={{ headerShown: false }}
/>
<Stack.Screen
name="list/[id]"
options={{ headerShown: false }}
/>
</Stack>
</ThemeProvider>
<KeyboardProvider>
<ThemeProvider value={effectiveScheme === 'dark' ? SimplDarkTheme : SimplLightTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="task/new"
options={{ presentation: 'modal', headerShown: false }}
/>
<Stack.Screen
name="task/[id]"
options={{ headerShown: false }}
/>
<Stack.Screen
name="list/[id]"
options={{ headerShown: false }}
/>
</Stack>
</ThemeProvider>
</KeyboardProvider>
</GestureHandlerRootView>
);
}

View file

@ -1,15 +1,14 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
ScrollView,
useColorScheme,
Alert,
Platform,
Keyboard,
} from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
ArrowLeft, Plus, Trash2, Calendar, X, Repeat, Download,
@ -81,22 +80,6 @@ export default function TaskDetailScreen() {
const [lists, setLists] = useState<{ id: string; name: string; color: string | null; icon: string | null; isInbox: boolean }[]>([]);
const [selectedListId, setSelectedListId] = useState<string>('');
const [saving, setSaving] = useState(false);
const scrollRef = useRef<ScrollView>(null);
const subtaskFocused = useRef(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
useEffect(() => {
const showSub = Keyboard.addListener('keyboardDidShow', (e) => {
setKeyboardHeight(e.endCoordinates.height);
if (subtaskFocused.current) {
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100);
}
});
const hideSub = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardHeight(0);
});
return () => { showSub.remove(); hideSub.remove(); };
}, []);
useEffect(() => {
if (!isValidUUID(id)) {
@ -226,7 +209,7 @@ export default function TaskDetailScreen() {
</View>
</View>
<ScrollView ref={scrollRef} className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
<KeyboardAwareScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled" bottomOffset={20}>
{/* Title */}
<TextInput
value={title}
@ -415,8 +398,6 @@ export default function TaskDetailScreen() {
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
onFocus={() => { subtaskFocused.current = true; }}
onBlur={() => { subtaskFocused.current = false; }}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
@ -424,8 +405,8 @@ export default function TaskDetailScreen() {
/>
</View>
<View style={{ height: keyboardHeight || 32 }} />
</ScrollView>
<View style={{ height: 32 }} />
</KeyboardAwareScrollView>
</View>
);
}

View file

@ -1,14 +1,13 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
Pressable,
ScrollView,
useColorScheme,
Platform,
Keyboard,
} from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
X, Calendar, Repeat, Plus,
@ -59,22 +58,6 @@ export default function NewTaskScreen() {
const [saving, setSaving] = useState(false);
const [pendingSubtasks, setPendingSubtasks] = useState<string[]>([]);
const [newSubtask, setNewSubtask] = useState('');
const scrollRef = useRef<ScrollView>(null);
const subtaskFocused = useRef(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
useEffect(() => {
const showSub = Keyboard.addListener('keyboardDidShow', (e) => {
setKeyboardHeight(e.endCoordinates.height);
if (subtaskFocused.current) {
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100);
}
});
const hideSub = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardHeight(0);
});
return () => { showSub.remove(); hideSub.remove(); };
}, []);
useEffect(() => {
getAllLists().then(setLists);
@ -153,7 +136,7 @@ export default function NewTaskScreen() {
</Pressable>
</View>
<ScrollView ref={scrollRef} className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
<KeyboardAwareScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled" bottomOffset={20}>
{/* Title */}
<TextInput
autoFocus
@ -385,8 +368,6 @@ export default function NewTaskScreen() {
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddPendingSubtask}
onFocus={() => { subtaskFocused.current = true; }}
onBlur={() => { subtaskFocused.current = false; }}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
@ -394,8 +375,8 @@ export default function NewTaskScreen() {
/>
</View>
<View style={{ height: keyboardHeight || 32 }} />
</ScrollView>
<View style={{ height: 32 }} />
</KeyboardAwareScrollView>
</View>
);
}

19
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "simpl-liste",
"version": "1.0.0",
"version": "1.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl-liste",
"version": "1.0.0",
"version": "1.2.2",
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ngrok": "^4.1.3",
@ -43,6 +43,7 @@
"react-native-android-widget": "^0.20.1",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@ -10395,6 +10396,20 @@
"react-native": "*"
}
},
"node_modules/react-native-keyboard-controller": {
"version": "1.18.5",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.18.5.tgz",
"integrity": "sha512-wbYN6Tcu3G5a05dhRYBgjgd74KqoYWuUmroLpigRg9cXy5uYo7prTMIvMgvLtARQtUF7BOtFggUnzgoBOgk0TQ==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-reanimated": ">=3.0.0"
}
},
"node_modules/react-native-reanimated": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",

View file

@ -44,6 +44,7 @@
"react-native-android-widget": "^0.20.1",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",