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:
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
app-expo/package-lock.json
generated
15
app-expo/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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