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:
@@ -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);
|
||||
};
|
||||
// iOS:Will* 与系统动画同步;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 逐字绑定。
|
||||
* iOS:WillShow 提前标记键盘区,便于底部 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user