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

@@ -46,11 +46,11 @@ This command will move the starter code to the **app-example** directory and cre
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android and iOS.
## Join the community
Join our community of developers creating universal apps.
Join our community of developers creating apps with Expo.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

View File

@@ -29,11 +29,6 @@ const LOCALES: Record<string, LocaleMessages> = {
const SUPPORTED_LOCALES = ['zh', 'en'] as const;
const PRIMARY_LOCALE = process.env.EXPO_PUBLIC_PRIMARY_LOCALE ?? 'zh';
const WEB_SQLITE_HEADERS = {
'Cross-Origin-Embedder-Policy': 'credentialless',
'Cross-Origin-Opener-Policy': 'same-origin',
} as const;
const PERMISSION_FALLBACKS: Record<PermissionKey, string> = {
microphone: 'Allow $(PRODUCT_NAME) to access your microphone.',
photos: 'Allow $(PRODUCT_NAME) to access your photos.',
@@ -137,6 +132,11 @@ export default ({ config }: ConfigContext): ExpoConfig => {
},
android: {
...config?.android,
/**
* `resize` → `adjustResize`:键盘顶起时缩小窗口,聊天输入框随内容上移。
* 与 `KeyboardAvoidingView` + `behavior="height"` 叠用会重复处理,易错位(见 Expo Keyboard 指南)。
*/
softwareKeyboardLayoutMode: 'resize',
// Reverse-DNS; no hyphens (Android package name rules). Matches iOS bundle id intent.
package: 'com.anonymous.appexpo',
adaptiveIcon: {
@@ -146,24 +146,10 @@ export default ({ config }: ConfigContext): ExpoConfig => {
},
predictiveBackGestureEnabled: false,
},
web: {
...config?.web,
bundler: 'metro',
output: 'static',
favicon: './assets/images/favicon.png',
},
plugins: [
// CI/local release: android/app/keystore.properties + store file → release signing; -PversionName/-PversionCode
'./plugins/withAndroidReleaseSigning',
// --- Web: Required for expo-sqlite on web ---
// COEP/COOP headers enable SharedArrayBuffer in browsers.
// Also configure metro.config.js (wasm + dev server headers).
[
'expo-router',
{
headers: WEB_SQLITE_HEADERS,
},
],
'expo-router',
[
'expo-splash-screen',
{

View File

@@ -3,29 +3,6 @@ const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
// --- Web: Required for expo-sqlite ---
// expo-sqlite on web uses SQLite compiled to WASM; Metro must treat .wasm as an asset.
if (!config.resolver.assetExts.includes('wasm')) {
config.resolver.assetExts.push('wasm');
}
// --- Web: Required for expo-sqlite ---
// SharedArrayBuffer (used by SQLite WASM) requires COEP/COOP headers.
// Dev server: set here. Production: set via app.config.js expo-router headers.
config.server = config.server ?? {};
const existingEnhanceMiddleware = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (middleware, server) => {
const nextMiddleware = existingEnhanceMiddleware
? existingEnhanceMiddleware(middleware, server)
: middleware;
return (req, res, next) => {
res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
return nextMiddleware(req, res, next);
};
};
module.exports = withNativeWind(config, {
input: './src/global.css',
inlineRem: 16,

View File

@@ -72,6 +72,7 @@
"react-i18next": "^16.5.8",
"react-native": "0.83.2",
"react-native-gesture-handler": "~2.30.0",
"react-native-keyboard-controller": "1.20.7",
"react-native-markdown-display": "^7.0.2",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
@@ -17401,6 +17402,20 @@
"react-native": "*"
}
},
"node_modules/react-native-keyboard-controller": {
"version": "1.20.7",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.20.7.tgz",
"integrity": "sha512-G8S5jz1FufPrcL1vPtReATx+jJhT/j+sTqxMIb30b1z7cYEfMlkIzOCyaHgf6IMB2KA9uBmnA5M6ve2A9Ou4kw==",
"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-markdown-display": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",

View File

@@ -91,6 +91,7 @@
"react-i18next": "^16.5.8",
"react-native": "0.83.2",
"react-native-gesture-handler": "~2.30.0",
"react-native-keyboard-controller": "1.20.7",
"react-native-markdown-display": "^7.0.2",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",

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,
},
});