From 5b39e5d0c00d434fac00250b0cee1fe71dc67f84 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:19:36 -0400 Subject: [PATCH] polish --- src/app/(tabs)/_layout.tsx | 17 ++++++----------- src/components/TabBar.tsx | 8 +++++--- src/navigation/tabTransition.ts | 20 ++++++++++++++++++++ 3 files changed, 31 insertions(+), 14 deletions(-) create mode 100644 src/navigation/tabTransition.ts diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index ca9e0f6..a2272f2 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -1,14 +1,12 @@ import { useMemo, useRef } from 'react'; import { Tabs } from 'expo-router'; -// RN's Easing (not reanimated): the bottom-tabs scene transition runs on legacy -// Animated.timing and may use the native driver, so the easing must be serializable. -import { Easing } from 'react-native'; import { TabBar, type TabItem } from '@/components/TabBar'; +import { + TAB_TRANSITION_SETTLE_MS, + TAB_TRANSITION_SPEC, +} from '@/navigation/tabTransition'; import { useColors } from '@/theme/themed'; -const TAB_TRANSITION_MS = 160; -const TAB_EASING = Easing.out(Easing.cubic); - export default function TabsLayout() { const colors = useColors(); const lastSwitchAt = useRef(0); @@ -22,10 +20,7 @@ export default function TabsLayout() { sceneStyle: { backgroundColor: colors.bgPrimary }, // Directional slide + cross-fade between tabs, following tab order. animation: 'shift' as const, - transitionSpec: { - animation: 'timing' as const, - config: { duration: TAB_TRANSITION_MS, easing: TAB_EASING }, - }, + transitionSpec: TAB_TRANSITION_SPEC, }), [colors.bgPrimary] ); @@ -45,7 +40,7 @@ export default function TabsLayout() { // completion frame and leave the incoming scene invisible; swallow // taps until the current transition has finished. const now = Date.now(); - if (now - lastSwitchAt.current < TAB_TRANSITION_MS + 30) return; + if (now - lastSwitchAt.current < TAB_TRANSITION_SETTLE_MS + 30) return; const event = navigation.emit({ type: 'tabPress', target: item.key, diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 310b45e..0ea7473 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -22,6 +22,7 @@ import { import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import { motion } from '@/theme/motion'; +import { TAB_TRANSITION_SETTLE_MS } from '@/navigation/tabTransition'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlayerStore } from '@/stores/playerStore'; @@ -30,8 +31,6 @@ import { playHaptic } from '@/lib/haptics'; type IconName = keyof typeof Ionicons.glyphMap; type MiniPlayerPhase = 'hidden' | 'reserved' | 'visible'; -const TAB_TRANSITION_MS = 160; - export const TAB_META: Record = { index: { label: 'Home', icon: 'home' }, library: { label: 'Library', icon: 'musical-notes' }, @@ -85,7 +84,10 @@ export function TabBar({ items, onPress }: TabBarProps) { return; } - const timer = setTimeout(() => setSettledHomeFocused(homeFocused), TAB_TRANSITION_MS); + const timer = setTimeout( + () => setSettledHomeFocused(homeFocused), + TAB_TRANSITION_SETTLE_MS + ); return () => clearTimeout(timer); }, [homeFocused, settledHomeFocused]); diff --git a/src/navigation/tabTransition.ts b/src/navigation/tabTransition.ts new file mode 100644 index 0000000..5b20c50 --- /dev/null +++ b/src/navigation/tabTransition.ts @@ -0,0 +1,20 @@ +/** + * Bottom tabs use React Native's legacy native Animated driver. On Android, + * timing animations are pre-sampled at 60 fps, so their positions repeat on a + * 120 Hz display. A critically damped native spring is evaluated from each + * display frame instead while preserving the current ~160 ms ease-out feel. + */ +export const TAB_TRANSITION_SETTLE_MS = 160; + +export const TAB_TRANSITION_SPEC = { + animation: 'spring', + config: { + stiffness: 2500, + damping: 100, + mass: 1, + overshootClamping: true, + restDisplacementThreshold: 0.004, + restSpeedThreshold: 0.15, + }, +} as const; +