diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx index a6ab74cd1a1..e404f303f2e 100644 --- a/apps/mobile/app/(auth)/login.tsx +++ b/apps/mobile/app/(auth)/login.tsx @@ -2,7 +2,7 @@ import { Stack } from 'expo-router'; import { X } from 'lucide-react-native'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Animated, Easing, Keyboard, Linking, Platform, Pressable, StyleSheet, View } from 'react-native'; +import { Animated, Easing, Keyboard, Linking, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { AccountDeletionStatus, SocialProvider, VerificationKind } from '@cindy/auth-client'; @@ -42,6 +42,7 @@ import { computeLoginKeyboardShift } from '@/auth/loginKeyboardAvoidance'; import { useLoginHandoffOptional } from '@/auth/MobileLoginHandoffContext'; import { createResendDeadline, + LOGIN_ACCOUNT_LIST, LOGIN_CONSENT_ROW, LOGIN_CONTROL, LOGIN_DELETION_BUBBLE, @@ -55,6 +56,7 @@ import { LOGIN_TITLE, resolveDeletionBubbleFrame, resolveDeletionBubbleLinkHitSlop, + resolveLoginAccountListLayout, type LoginDeletionBubbleFrame, type LoginSurfaceMode, } from '@/auth/loginSkinLayout'; @@ -278,9 +280,19 @@ export function LoginScreen({ const [accountDeletionStatus, setAccountDeletionStatus] = useState(null); const [accountSwitcherVisible, setAccountSwitcherVisible] = useState(false); + const accountSelectionScrollRef = useRef(null); const styles = useThemedStyles(makeStyles); const configIssues = getMobileConfigIssues(); const disabled = auth.isBusy || !auth.initialized || configIssues.length > 0; + const accountSelectionCount = + auth.loginState?.step === 'account-selection' + ? auth.loginState.accounts.length + : 0; + + useEffect(() => { + if (accountSelectionCount <= LOGIN_ACCOUNT_LIST.visibleRowCount) return; + accountSelectionScrollRef.current?.flashScrollIndicators(); + }, [accountSelectionCount]); useEffect(() => { let cancelled = false; @@ -918,6 +930,7 @@ export function LoginScreen({ const renderAccountSelection = () => { const state = auth.loginState; if (state?.step !== 'account-selection') return null; + const listLayout = resolveLoginAccountListLayout(state.accounts.length); return ( {backNode} @@ -925,29 +938,48 @@ export function LoginScreen({ title={loginText('chooseAccount')} subtitle={loginText('chooseAccountSubtitle')} /> - {state.accounts.map((account, index) => ( - - void auth.dispatchLoginAction({ - type: 'select-account', - accountId: account.id, - }) - } - subtitle={ - account.orgName || account.email || loginText('personalAccount') - } - testID={`login.account.${account.id}`} - title={account.displayName} - top={ - LOGIN_METHOD_ROW.firstRowTopSsoOrg + - index * LOGIN_METHOD_ROW.rowStep - } - /> - ))} + LOGIN_ACCOUNT_LIST.visibleRowCount + } + showsVerticalScrollIndicator={ + state.accounts.length > LOGIN_ACCOUNT_LIST.visibleRowCount + } + style={{ + height: listLayout.viewportHeight, + left: 0, + position: 'absolute', + top: LOGIN_ACCOUNT_LIST.viewportTop, + width: loginSizes.panelWidth, + }} + testID="login.accountList" + > + {state.accounts.map((account, index) => ( + + void auth.dispatchLoginAction({ + type: 'select-account', + accountId: account.id, + }) + } + subtitle={ + account.orgName || account.email || loginText('personalAccount') + } + testID={`login.account.${account.id}`} + title={account.displayName} + top={index * LOGIN_METHOD_ROW.rowStep} + /> + ))} + {errorNode} ); diff --git a/apps/mobile/src/auth/__tests__/loginSkinLayout.test.ts b/apps/mobile/src/auth/__tests__/loginSkinLayout.test.ts index e709c089d0e..1961508bebc 100644 --- a/apps/mobile/src/auth/__tests__/loginSkinLayout.test.ts +++ b/apps/mobile/src/auth/__tests__/loginSkinLayout.test.ts @@ -12,8 +12,11 @@ vi.mock("expo-localization", () => ({ import { createResendDeadline, formatResendCountdown, + LOGIN_ACCOUNT_LIST, LOGIN_CONTROL, LOGIN_DELETION_BUBBLE, + LOGIN_ERROR_TEXT, + LOGIN_METHOD_ROW, LOGIN_PAD_LANDSCAPE_STAGE, LOGIN_PAD_PORTRAIT_STAGE, LOGIN_STAGE_LONG, @@ -24,6 +27,7 @@ import { resendCountdownRemaining, resolveDeletionBubbleFrame, resolveDeletionBubbleLinkHitSlop, + resolveLoginAccountListLayout, resolveLoginStage, resolveLoginSurface, resolveLoginSurfaceMode, @@ -40,6 +44,24 @@ function expectBox(actual: LoginStageBox, expected: LoginStageBox) { } describe("loginSkin 750 stage 布局引擎", () => { + it("五个身份使用独立滚动视口:前两项位置不变,末项可完整滚入", () => { + const layout = resolveLoginAccountListLayout(5); + + expect(LOGIN_ACCOUNT_LIST.viewportTop).toBe( + LOGIN_METHOD_ROW.firstRowTopSsoOrg, + ); + expect(layout.rowTops.slice(0, 2)).toEqual([0, LOGIN_METHOD_ROW.rowStep]); + expect(layout.viewportHeight).toBe( + LOGIN_ERROR_TEXT.y - LOGIN_METHOD_ROW.firstRowTopSsoOrg, + ); + expect(layout.contentHeight).toBe( + LOGIN_METHOD_ROW.height + 4 * LOGIN_METHOD_ROW.rowStep, + ); + expect(layout.lastRowTopAtMaxScroll + LOGIN_METHOD_ROW.height).toBe( + layout.viewportHeight, + ); + }); + it("scale 与 designHeight clamp:vw/750 缩放,dh clamp [600,1800]", () => { const layout = resolveLoginStage(390, 844); expect(layout.scale).toBeCloseTo(390 / 750, 10); diff --git a/apps/mobile/src/auth/__tests__/loginSkinStates.test.ts b/apps/mobile/src/auth/__tests__/loginSkinStates.test.ts index 36d87e29b78..69be3e64602 100644 --- a/apps/mobile/src/auth/__tests__/loginSkinStates.test.ts +++ b/apps/mobile/src/auth/__tests__/loginSkinStates.test.ts @@ -289,6 +289,10 @@ describe('loginSkin 全登录态(harness 真链 + 渲染层接线)', () => { expect(loginSource).toContain( "account.orgName || account.email || loginText('personalAccount')", ); + expect(loginSource).toContain('ref={accountSelectionScrollRef}'); + expect(loginSource).toContain('showsVerticalScrollIndicator={'); + expect(loginSource).toContain('resolveLoginAccountListLayout('); + expect(loginSource).toContain('top={index * LOGIN_METHOD_ROW.rowStep}'); }); it('binding-contact:outcome:binding-phone 真链→绑定阶段一(输入+发送)', async () => { diff --git a/apps/mobile/src/auth/loginSkinLayout.ts b/apps/mobile/src/auth/loginSkinLayout.ts index 5c77d2afff7..6cf5eade8b5 100644 --- a/apps/mobile/src/auth/loginSkinLayout.ts +++ b/apps/mobile/src/auth/loginSkinLayout.ts @@ -441,6 +441,53 @@ export const LOGIN_METHOD_ROW = { firstRowTopSsoOrg: 148, rowStep: 120, } as const; + +/** + * 身份选择列表的独立滚动视口。 + * + * 标题 / 返回按钮留在面板固定层;视口从原首行 y=148 开始,到错误提示槽 y=380 + * 结束,因此默认完整显示两行且不会与错误文案重叠。更多身份在该区域内滚动。 + */ +export const LOGIN_ACCOUNT_LIST = { + viewportTop: LOGIN_METHOD_ROW.firstRowTopSsoOrg, + viewportHeight: + LOGIN_ERROR_TEXT.y - LOGIN_METHOD_ROW.firstRowTopSsoOrg, + visibleRowCount: 2, +} as const; + +export interface LoginAccountListLayout { + viewportHeight: number; + contentHeight: number; + maxScrollOffset: number; + lastRowTopAtMaxScroll: number; + rowTops: number[]; +} + +/** 纯几何:给定身份数,计算滚动内容高度与最大滚动位置。 */ +export function resolveLoginAccountListLayout( + accountCount: number, +): LoginAccountListLayout { + const count = Math.max(0, Math.floor(accountCount)); + const rowTops = Array.from( + { length: count }, + (_, index) => index * LOGIN_METHOD_ROW.rowStep, + ); + const rowsHeight = + count === 0 + ? 0 + : LOGIN_METHOD_ROW.height + (count - 1) * LOGIN_METHOD_ROW.rowStep; + const contentHeight = Math.max(LOGIN_ACCOUNT_LIST.viewportHeight, rowsHeight); + const maxScrollOffset = contentHeight - LOGIN_ACCOUNT_LIST.viewportHeight; + const lastRowTop = rowTops.at(-1) ?? 0; + + return { + viewportHeight: LOGIN_ACCOUNT_LIST.viewportHeight, + contentHeight, + maxScrollOffset, + lastRowTopAtMaxScroll: lastRowTop - maxScrollOffset, + rowTops, + }; +} /** 大 loading 环(figma §5.2:64×64 @(308,158 browser / 193 preparing))。 */ export const LOGIN_LOADING_RING = { x: 308, yBrowser: 158, yPreparing: 193, size: 64 } as const; /** Text_link / 倒计时(figma §4.7:@(70,238) 540×50 20;行高走 LOGIN_COPY_LINE_HEIGHT)。 */ diff --git a/docs/design-previews/mobile-login-account-list-scroll/evidence/01-entry-real-iphone.webp b/docs/design-previews/mobile-login-account-list-scroll/evidence/01-entry-real-iphone.webp new file mode 100644 index 00000000000..0e78c904010 Binary files /dev/null and b/docs/design-previews/mobile-login-account-list-scroll/evidence/01-entry-real-iphone.webp differ diff --git a/docs/design-previews/mobile-login-account-list-scroll/evidence/02-scroll-middle-real-iphone.webp b/docs/design-previews/mobile-login-account-list-scroll/evidence/02-scroll-middle-real-iphone.webp new file mode 100644 index 00000000000..94caa61aea5 Binary files /dev/null and b/docs/design-previews/mobile-login-account-list-scroll/evidence/02-scroll-middle-real-iphone.webp differ diff --git a/docs/design-previews/mobile-login-account-list-scroll/evidence/03-scroll-bottom-real-iphone.webp b/docs/design-previews/mobile-login-account-list-scroll/evidence/03-scroll-bottom-real-iphone.webp new file mode 100644 index 00000000000..fcc6c5d31e5 Binary files /dev/null and b/docs/design-previews/mobile-login-account-list-scroll/evidence/03-scroll-bottom-real-iphone.webp differ