From 9da27254f0e739f93d08018b2f74800be9483d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=A1=E5=9B=BA=E6=89=A7=E7=9A=84=E9=B1=BC?= <1504947133@qq.com> Date: Fri, 21 Aug 2026 15:14:17 +0800 Subject: [PATCH] fix Android login keyboard focus --- frontend/src/screens/LoginScreen.tsx | 228 +++++++++--------- .../tests/unit/screens/LoginScreen.test.tsx | 39 +-- 2 files changed, 121 insertions(+), 146 deletions(-) diff --git a/frontend/src/screens/LoginScreen.tsx b/frontend/src/screens/LoginScreen.tsx index 43cf85ac..7968bac8 100644 --- a/frontend/src/screens/LoginScreen.tsx +++ b/frontend/src/screens/LoginScreen.tsx @@ -1,6 +1,7 @@ import { StatusBar } from 'expo-status-bar'; import { useRef, useState } from 'react'; import { + KeyboardAvoidingView, Platform, Pressable, ScrollView, @@ -13,7 +14,6 @@ import { import { useAuthAccessForm } from '../features/auth/presentation/useAuthAccessForm'; import { colors, spacing } from '../shared/ui/theme'; -import { useImeOverlap } from '../shared/ui/useImeOverlap'; type FocusedField = 'username' | 'password' | null; @@ -21,131 +21,132 @@ export function LoginScreen() { const passwordInputRef = useRef(null); const { width: windowWidth } = useWindowDimensions(); const [focusedField, setFocusedField] = useState(null); - const imeOverlap = useImeOverlap(); const { errors, isSubmitting, submit, submitError, updateField, values } = useAuthAccessForm(); return ( - - + - - - - Time - flow - - - - - - 登录 - 首次使用会自动创建账号。 - - - - - 用户名 - setFocusedField(null)} - onChangeText={(value) => updateField('username', value)} - onFocus={() => setFocusedField('username')} - onSubmitEditing={() => passwordInputRef.current?.focus()} - placeholder="输入用户名" - placeholderTextColor={colors.mutedText} - selectionColor={colors.focus} - style={[ - styles.input, - focusedField === 'username' && styles.inputFocused, - errors.username && styles.inputError, - ]} - textContentType="username" - value={values.username} - /> - {errors.username ? ( - - {errors.username} - - ) : null} - + + + Time + flow + - - 密码 - setFocusedField(null)} - onChangeText={(value) => updateField('password', value)} - onFocus={() => setFocusedField('password')} - onSubmitEditing={submit} - placeholder="输入密码" - placeholderTextColor={colors.mutedText} - secureTextEntry - selectionColor={colors.focus} - style={[ - styles.input, - focusedField === 'password' && styles.inputFocused, - errors.password && styles.inputError, - ]} - textContentType="password" - value={values.password} - /> - {errors.password ? ( - - {errors.password} - - ) : null} - + + + + 登录 + 首次使用会自动创建账号。 + - {submitError ? ( - - {submitError} + + + 用户名 + setFocusedField(null)} + onChangeText={(value) => updateField('username', value)} + onFocus={() => setFocusedField('username')} + onSubmitEditing={() => passwordInputRef.current?.focus()} + placeholder="输入用户名" + placeholderTextColor={colors.mutedText} + selectionColor={colors.focus} + style={[ + styles.input, + focusedField === 'username' && styles.inputFocused, + errors.username && styles.inputError, + ]} + textContentType="username" + value={values.username} + /> + {errors.username ? ( + + {errors.username} ) : null} + - [ - styles.loginButton, - isSubmitting && styles.loginButtonDisabled, - pressed && !isSubmitting && styles.loginButtonPressed, + + 密码 + setFocusedField(null)} + onChangeText={(value) => updateField('password', value)} + onFocus={() => setFocusedField('password')} + onSubmitEditing={submit} + placeholder="输入密码" + placeholderTextColor={colors.mutedText} + secureTextEntry + selectionColor={colors.focus} + style={[ + styles.input, + focusedField === 'password' && styles.inputFocused, + errors.password && styles.inputError, ]} - > - {isSubmitting ? '提交中…' : '继续'} - + textContentType="password" + value={values.password} + /> + {errors.password ? ( + + {errors.password} + + ) : null} + + {submitError ? ( + + {submitError} + + ) : null} + + [ + styles.loginButton, + isSubmitting && styles.loginButtonDisabled, + pressed && !isSubmitting && styles.loginButtonPressed, + ]} + > + {isSubmitting ? '提交中…' : '继续'} + - - - 你的日程只属于你 - - - + + + 你的日程只属于你 + + + - + ); } @@ -182,10 +183,6 @@ const styles = StyleSheet.create({ inputFocused: { borderColor: colors.focus, }, - imeLift: { - flex: 1, - width: '100%', - }, intro: { marginBottom: 28, }, @@ -234,7 +231,6 @@ const styles = StyleSheet.create({ screen: { backgroundColor: colors.background, flex: 1, - overflow: 'hidden', }, scrollContent: { flexGrow: 1, diff --git a/frontend/tests/unit/screens/LoginScreen.test.tsx b/frontend/tests/unit/screens/LoginScreen.test.tsx index 975661f4..08a74a5f 100644 --- a/frontend/tests/unit/screens/LoginScreen.test.tsx +++ b/frontend/tests/unit/screens/LoginScreen.test.tsx @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react-native'; -import { Dimensions, Image, Keyboard, StyleSheet, type KeyboardEvent } from 'react-native'; +import { Image, KeyboardAvoidingView, ScrollView, StyleSheet } from 'react-native'; import { AuthAccessError, type AuthAccess } from '../../../src/contracts/auth'; import { AuthController } from '../../../src/features/auth/application'; @@ -68,24 +68,18 @@ describe('LoginScreen', () => { expect(screen.getByLabelText('密码').props.secureTextEntry).toBe(true); }); - it('focuses fields, advances to the password, and lifts with the IME', () => { - const listeners = new Map void>(); - jest.spyOn(Keyboard, 'addListener').mockImplementation((event, listener) => { - listeners.set(event, listener as (event: KeyboardEvent) => void); - return { - remove() { - listeners.delete(event); - }, - } as ReturnType; - }); - jest.spyOn(Keyboard, 'scheduleLayoutAnimation').mockImplementation(() => {}); - + it('keeps field focus transitions inside a native keyboard-avoiding layout', () => { renderLogin(async () => ({ access_token: 'opaque-token', account_id: 'acc_001', expires_in: 3600, })); + const keyboardContainer = screen.UNSAFE_getByType(KeyboardAvoidingView); + const scrollView = screen.UNSAFE_getByType(ScrollView); + expect(keyboardContainer.props.behavior).toBeTruthy(); + expect(scrollView.props.keyboardDismissMode).not.toBe('on-drag'); + const username = screen.getByLabelText('用户名'); const password = screen.getByLabelText('密码'); fireEvent(username, 'focus'); @@ -96,21 +90,6 @@ describe('LoginScreen', () => { expect(StyleSheet.flatten(password.props.style)).toMatchObject({ borderColor: colors.focus }); fireEvent(password, 'blur'); fireEvent(screen.getByRole('button', { name: '继续' }), 'pressIn'); - - const windowHeight = Dimensions.get('window').height; - act(() => { - listeners.get('keyboardWillChangeFrame')?.({ - duration: 0, - easing: 'keyboard', - endCoordinates: { - height: 300, - screenX: 0, - screenY: windowHeight - 300, - width: 400, - }, - }); - }); - expect(screen.getByTestId('login-ime-lift')).toHaveStyle({ transform: [{ translateY: -300 }] }); }); it('disables fields and the submit control while authenticating', async () => { @@ -139,14 +118,14 @@ describe('LoginScreen', () => { }); }); - it('keeps the login sheet unshifted until the IME covers the window', () => { + it('does not apply a parent transform when the login screen renders', () => { renderLogin(async () => ({ access_token: 'opaque-token', account_id: 'acc_001', expires_in: 3600, })); - expect(screen.getByTestId('login-ime-lift')).toHaveStyle({ transform: [{ translateY: 0 }] }); + expect(screen.queryByTestId('login-ime-lift')).toBeNull(); }); it('validates empty fields without authenticating', async () => {