feat(app-expo): 聊天键盘与列表滚动改用 keyboard-controller,并收敛 Web 构建配置

聊天(conversation/[id])
- 根布局挂载 KeyboardProvider,会话页使用 react-native-keyboard-controller 的
  KeyboardAvoidingView(padding + 仅文字模式 enabled),替代手写 keyboardLift 与
  RN KeyboardAvoidingView 分端逻辑,改善 Android 键盘遮挡与布局一致性。
- 键盘:keyboardDidShow 后 scrollToEnd;iOS 用 keyboardWillShow 提前更新键盘可见状态;
  收起使用 WillHide/DidHide;监听在 effect 中统一移除。
- 输入框高度:ChatInputBar 通过 onInputDisplayHeightChange 在 inputDisplayHeight 变化时
  触发滚到底;保留底部容器 onLayout 以覆盖连接提示与整块高度变化。

配置与构建
- app.config:移除 web 块与 expo-sqlite Web 所需的 COEP/COOP headers;expo-router 插件
  改为无参;Android 显式 softwareKeyboardLayoutMode: resize。
- metro.config:移除 wasm 资源与 COOP/COOP dev server
OC
This commit is contained in:
Kevin
2026-03-23 14:20:12 +08:00
parent f58adb9670
commit 62de478368
9 changed files with 90 additions and 219 deletions

View File

@@ -13,7 +13,6 @@ import {
FlatList,
InteractionManager,
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
@@ -21,6 +20,7 @@ import {
TextInput,
View,
} from 'react-native';
import { KeyboardAvoidingView as KeyboardControllerAvoidingView } from 'react-native-keyboard-controller';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
@@ -438,6 +438,7 @@ function ChatInputBar({
cancelRecordingLabel,
disabled,
textInputKey = 0,
onInputDisplayHeightChange,
}: {
value: string;
onChangeText: (v: string) => void;
@@ -462,6 +463,8 @@ function ChatInputBar({
disabled?: boolean;
/** 发送后递增,强制重建 TextInput避免多行高度卡在 4 行 */
textInputKey?: number;
/** 文字输入框实际绘制高度变化(单行/多行),供列表滚到底 */
onInputDisplayHeightChange?: (height: number) => void;
}) {
const colors = useThemeColors();
const [textHeight, setTextHeight] = useState(CHAT_INPUT_LINE_H);
@@ -474,6 +477,11 @@ function ChatInputBar({
}
}, [value]);
useEffect(() => {
if (inputMode !== 'text' || !onInputDisplayHeightChange) return;
onInputDisplayHeightChange(inputDisplayHeight);
}, [inputDisplayHeight, inputMode, onInputDisplayHeightChange]);
const onInputContentSizeChange = useCallback(
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
const h = e.nativeEvent.contentSize.height;
@@ -668,36 +676,10 @@ export default function ConversationScreen() {
const [inputResetKey, setInputResetKey] = useState(0);
const [inputMode, setInputMode] = useState<InputMode>('text');
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const listRef = useRef<FlatList>(null);
/** 底部输入区(含连接提示 + 输入条)高度,用于多行输入增高时把列表滚到底,避免挡住最新消息 */
const composerBlockHeightRef = useRef<number | null>(null);
useEffect(() => {
const onShow = (e: { endCoordinates: { height: number } }) => {
setIsKeyboardVisible(true);
setKeyboardHeight(e.endCoordinates.height);
InteractionManager.runAfterInteractions(() => {
listRef.current?.scrollToEnd({ animated: true });
});
};
const onHide = () => {
setIsKeyboardVisible(false);
setKeyboardHeight(0);
};
// iOSWill* 与系统动画同步KeyboardAvoidingView 在 iOS 上易与 safe area 叠出缝(见 RN #52626
const showEvt =
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvt =
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const subShow = Keyboard.addListener(showEvt, onShow);
const subHide = Keyboard.addListener(hideEvt, onHide);
return () => {
subShow.remove();
subHide.remove();
};
}, [insets.bottom]);
const flattenedData = flattenMessagesForList(messages ?? []);
const isRecording = recorderStatus === 'recording';
@@ -718,6 +700,36 @@ export default function ConversationScreen() {
});
}, []);
/**
* 仅随系统键盘DidShow 时布局已稳定再 scrollToEnd不与 input 逐字绑定。
* iOSWillShow 提前标记键盘区,便于底部 inset 与动画同步。
*/
useEffect(() => {
const onKeyboardShown = () => {
setIsKeyboardVisible(true);
scrollListToEndAfterComposerLayout();
};
const onKeyboardHidden = () => {
setIsKeyboardVisible(false);
};
const subs: ReturnType<typeof Keyboard.addListener>[] = [];
if (Platform.OS === 'ios') {
subs.push(
Keyboard.addListener('keyboardWillShow', () =>
setIsKeyboardVisible(true),
),
);
}
subs.push(Keyboard.addListener('keyboardDidShow', onKeyboardShown));
subs.push(
Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
onKeyboardHidden,
),
);
return () => subs.forEach((s) => s.remove());
}, [scrollListToEndAfterComposerLayout]);
const onComposerBlockLayout = useCallback(
(e: LayoutChangeEvent) => {
const h = e.nativeEvent.layout.height;
@@ -759,14 +771,7 @@ export default function ConversationScreen() {
? t('chatUnavailableConnecting')
: t('chatUnavailableDisconnected');
/** iOS用键盘高度直接顶起根布局替代 KAV避免与 safe area 叠出缝,见 RN #52626 */
const keyboardLift =
Platform.OS === 'ios' && inputMode === 'text' && isKeyboardVisible
? keyboardHeight
: 0;
const androidKavOn =
Platform.OS === 'android' && inputMode === 'text' && isKeyboardVisible;
/** 键盘打开时去掉底部 safe area避免与键盘区重复留白 */
const composerZeroBottomInset = isKeyboardVisible && inputMode === 'text';
const screen = (
@@ -886,26 +891,20 @@ export default function ConversationScreen() {
tapToEndLabel={t('tapToEndRecording')}
cancelRecordingLabel={t('cancelRecording')}
disabled={connectionState !== 'connected'}
onInputDisplayHeightChange={scrollListToEndAfterComposerLayout}
/>
</View>
</View>
);
if (Platform.OS === 'ios') {
return (
<View style={[styles.container, { paddingBottom: keyboardLift }]}>
{screen}
</View>
);
}
return (
<KeyboardAvoidingView
<KeyboardControllerAvoidingView
style={styles.container}
behavior={androidKavOn ? 'height' : undefined}
behavior="padding"
enabled={inputMode === 'text'}
>
{screen}
</KeyboardAvoidingView>
</KeyboardControllerAvoidingView>
);
}

View File

@@ -51,7 +51,7 @@ export default function DeleteDataScreen() {
<ScreenHeader title={t('dataPrivacy.deleteAll')} />
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === 'web' ? undefined : 'padding'}
behavior="padding"
keyboardVerticalOffset={headerKeyboardOffset(insets.top)}
>
<ScrollView

View File

@@ -3,6 +3,7 @@ import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import {
SafeAreaProvider,
initialWindowMetrics,
@@ -46,23 +47,25 @@ export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<AppProviders>
<TypographyProvider>
<ThemeVariablesProvider>
<NavigationThemeProvider>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<AnimatedSplashOverlay />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(main)" />
<Stack.Screen name="legal" />
</Stack>
<PortalHost />
</NavigationThemeProvider>
</ThemeVariablesProvider>
</TypographyProvider>
</AppProviders>
<KeyboardProvider>
<AppProviders>
<TypographyProvider>
<ThemeVariablesProvider>
<NavigationThemeProvider>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<AnimatedSplashOverlay />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(main)" />
<Stack.Screen name="legal" />
</Stack>
<PortalHost />
</NavigationThemeProvider>
</ThemeVariablesProvider>
</TypographyProvider>
</AppProviders>
</KeyboardProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);

View File

@@ -1,110 +0,0 @@
import {
Tabs,
TabList,
TabTrigger,
TabSlot,
TabTriggerSlotProps,
TabListProps,
} from 'expo-router/ui';
import { SymbolView } from 'expo-symbols';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, View, StyleSheet } from 'react-native';
import { useThemeColors } from '@/hooks/use-theme-colors';
import { ExternalLink } from './external-link';
import { Text } from '@/components/ui/text';
export default function AppTabs() {
const { t } = useTranslation('app');
return (
<Tabs>
<TabSlot style={{ height: '100%' }} />
<TabList asChild>
<CustomTabList>
<TabTrigger name="home" href="/" asChild>
<TabButton>{t('tabs.home')}</TabButton>
</TabTrigger>
<TabTrigger name="explore" href="/explore" asChild>
<TabButton>{t('tabs.explore')}</TabButton>
</TabTrigger>
</CustomTabList>
</TabList>
</Tabs>
);
}
export function TabButton({
children,
isFocused,
...props
}: TabTriggerSlotProps) {
return (
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
<View
className={
isFocused
? 'rounded-2xl bg-accent px-4 py-1'
: 'rounded-2xl bg-card px-4 py-1'
}
>
<Text
className={
isFocused
? 'text-sm text-foreground'
: 'text-sm text-muted-foreground'
}
>
{children}
</Text>
</View>
</Pressable>
);
}
export function CustomTabList(props: TabListProps) {
const colors = useThemeColors();
const { t } = useTranslation(['app', 'common']);
return (
<View
{...props}
className="absolute w-full flex-row items-center justify-center p-4"
>
<View className="w-full max-w-content flex-grow flex-row items-center gap-2 rounded-[32px] bg-card px-8 py-2">
<Text className="mr-auto text-sm font-bold text-foreground">
{t('name')}
</Text>
{props.children}
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={styles.externalPressable}>
<Text className="text-sm text-primary underline-offset-4">
{t('docs', { ns: 'common' })}
</Text>
<SymbolView
tintColor={colors.text}
name={{ ios: 'arrow.up.right.square', web: 'link' }}
size={12}
/>
</Pressable>
</ExternalLink>
</View>
</View>
);
}
const styles = StyleSheet.create({
pressed: {
opacity: 0.7,
},
externalPressable: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 4,
marginLeft: 16,
},
});