From 62de478368947488393e6edfd4608829ec4ca338 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 23 Mar 2026 14:20:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-expo):=20=E8=81=8A=E5=A4=A9=E9=94=AE?= =?UTF-8?q?=E7=9B=98=E4=B8=8E=E5=88=97=E8=A1=A8=E6=BB=9A=E5=8A=A8=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20keyboard-controller=EF=BC=8C=E5=B9=B6=E6=94=B6?= =?UTF-8?q?=E6=95=9B=20Web=20=E6=9E=84=E5=BB=BA=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聊天(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 --- app-expo/README.md | 4 +- app-expo/app.config.ts | 26 +---- app-expo/metro.config.js | 23 ---- app-expo/package-lock.json | 15 +++ app-expo/package.json | 1 + app-expo/src/app/(main)/conversation/[id].tsx | 91 +++++++-------- app-expo/src/app/(main)/delete-data.tsx | 2 +- app-expo/src/app/_layout.tsx | 37 +++--- app-expo/src/components/app-tabs.web.tsx | 110 ------------------ 9 files changed, 90 insertions(+), 219 deletions(-) delete mode 100644 app-expo/src/components/app-tabs.web.tsx diff --git a/app-expo/README.md b/app-expo/README.md index 4d67aec..3ec0049 100644 --- a/app-expo/README.md +++ b/app-expo/README.md @@ -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. diff --git a/app-expo/app.config.ts b/app-expo/app.config.ts index accbb2a..1310211 100644 --- a/app-expo/app.config.ts +++ b/app-expo/app.config.ts @@ -29,11 +29,6 @@ const LOCALES: Record = { 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 = { 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', { diff --git a/app-expo/metro.config.js b/app-expo/metro.config.js index 443918a..5c0bb4c 100644 --- a/app-expo/metro.config.js +++ b/app-expo/metro.config.js @@ -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, diff --git a/app-expo/package-lock.json b/app-expo/package-lock.json index e4fe63f..9a4059f 100644 --- a/app-expo/package-lock.json +++ b/app-expo/package-lock.json @@ -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", diff --git a/app-expo/package.json b/app-expo/package.json index b800706..edb06b7 100644 --- a/app-expo/package.json +++ b/app-expo/package.json @@ -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", diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index 91cbd95..2833021 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -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) => { const h = e.nativeEvent.contentSize.height; @@ -668,36 +676,10 @@ export default function ConversationScreen() { const [inputResetKey, setInputResetKey] = useState(0); const [inputMode, setInputMode] = useState('text'); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); - const [keyboardHeight, setKeyboardHeight] = useState(0); const listRef = useRef(null); /** 底部输入区(含连接提示 + 输入条)高度,用于多行输入增高时把列表滚到底,避免挡住最新消息 */ const composerBlockHeightRef = useRef(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[] = []; + 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} /> ); - if (Platform.OS === 'ios') { - return ( - - {screen} - - ); - } - return ( - {screen} - + ); } diff --git a/app-expo/src/app/(main)/delete-data.tsx b/app-expo/src/app/(main)/delete-data.tsx index 0644bf1..da21aa5 100644 --- a/app-expo/src/app/(main)/delete-data.tsx +++ b/app-expo/src/app/(main)/delete-data.tsx @@ -51,7 +51,7 @@ export default function DeleteDataScreen() { - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ); diff --git a/app-expo/src/components/app-tabs.web.tsx b/app-expo/src/components/app-tabs.web.tsx deleted file mode 100644 index 52f9073..0000000 --- a/app-expo/src/components/app-tabs.web.tsx +++ /dev/null @@ -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 ( - - - - - - {t('tabs.home')} - - - {t('tabs.explore')} - - - - - ); -} - -export function TabButton({ - children, - isFocused, - ...props -}: TabTriggerSlotProps) { - return ( - pressed && styles.pressed}> - - - {children} - - - - ); -} - -export function CustomTabList(props: TabListProps) { - const colors = useThemeColors(); - const { t } = useTranslation(['app', 'common']); - - return ( - - - - {t('name')} - - - {props.children} - - - - - {t('docs', { ns: 'common' })} - - - - - - - ); -} - -const styles = StyleSheet.create({ - pressed: { - opacity: 0.7, - }, - externalPressable: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: 4, - marginLeft: 16, - }, -});