diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index fd1dbda..5294d60 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -1,4 +1,7 @@ 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 { colors } from '@/theme'; @@ -8,6 +11,12 @@ export default function TabsLayout() { screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bgPrimary }, + // Directional slide + cross-fade between tabs, following tab order. + animation: 'shift', + transitionSpec: { + animation: 'timing', + config: { duration: 200, easing: Easing.out(Easing.cubic) }, + }, }} tabBar={({ state, navigation }) => { const items: TabItem[] = state.routes.map((route, index) => ({ diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 8a99e65..4146ff9 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -1,9 +1,16 @@ -import { View, Pressable, StyleSheet } from 'react-native'; +import { useEffect } from 'react'; +import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; -import { Text } from './Text'; +import Animated, { + interpolateColor, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import { MiniPlayer } from './MiniPlayer'; -import { colors, layout, spacing } from '@/theme'; +import { colors, fonts, layout, spacing } from '@/theme'; +import { motion } from '@/theme/motion'; type IconName = keyof typeof Ionicons.glyphMap; @@ -32,7 +39,34 @@ interface TabBarProps { */ export function TabBar({ items, onPress }: TabBarProps) { const insets = useSafeAreaInsets(); + const tabs = items.filter((item) => TAB_META[item.name]); const homeFocused = items.some((item) => item.name === 'index' && item.focused); + const count = tabs.length; + const activeIndex = Math.max( + 0, + tabs.findIndex((item) => item.focused), + ); + + // The "playhead": a single accent bar that glides along the top edge to the + // active tab, travelling in the same direction as the scene transition. + const barWidth = useSharedValue(0); + const position = useSharedValue(activeIndex); + + useEffect(() => { + position.value = withTiming(activeIndex, motion.snap); + }, [activeIndex, position]); + + const indicatorStyle = useAnimatedStyle(() => { + const segment = count > 0 ? barWidth.value / count : 0; + return { + width: segment, + transform: [{ translateX: position.value * segment }], + }; + }); + + const onBarLayout = (e: LayoutChangeEvent) => { + barWidth.value = e.nativeEvent.layout.width; + }; return ( @@ -42,25 +76,21 @@ export function TabBar({ items, onPress }: TabBarProps) { styles.bar, { paddingBottom: insets.bottom, height: layout.tabBarHeight + insets.bottom }, ]} + onLayout={onBarLayout} > - {items.map((item) => { + + + + {tabs.map((item) => { const meta = TAB_META[item.name]; if (!meta) return null; - const color = item.focused ? colors.accent : colors.textTertiary; return ( - onPress(item)} - hitSlop={8} - accessibilityRole="tab" - accessibilityState={{ selected: item.focused }} - > - - - {meta.label} - - + /> ); })} @@ -68,6 +98,67 @@ export function TabBar({ items, onPress }: TabBarProps) { ); } +interface TabButtonProps { + meta: { label: string; icon: IconName }; + focused: boolean; + onPress: () => void; +} + +/** + * A single tab. Selecting it cross-fades the icon/label colour to accent; + * pressing depresses the icon for tactile feedback. Reanimated only drives + * opacity/transform on plain Animated.Views — @expo/vector-icons' Icon is a + * class-wrapped Text that Animated can't drive, so colour is a cross-fade + * between a grey base icon and an accent one stacked on top. Spring-free per + * theme/motion. + */ +function TabButton({ meta, focused, onPress }: TabButtonProps) { + // 0 = inactive, 1 = active. Drives the accent fill, label colour, and bloom. + const progress = useSharedValue(focused ? 1 : 0); + // 0 = at rest, 1 = finger down. + const press = useSharedValue(0); + + useEffect(() => { + progress.value = withTiming(focused ? 1 : 0, motion.quick); + }, [focused, progress]); + + const depressStyle = useAnimatedStyle(() => ({ + transform: [{ scale: 1 - press.value * 0.12 }], + })); + const accentStyle = useAnimatedStyle(() => ({ opacity: progress.value })); + const labelStyle = useAnimatedStyle(() => ({ + color: interpolateColor( + progress.value, + [0, 1], + [colors.textTertiary, colors.accent], + ), + })); + + return ( + { + press.value = withTiming(1, motion.quick); + }} + onPressOut={() => { + press.value = withTiming(0, motion.quick); + }} + hitSlop={8} + accessibilityRole="tab" + accessibilityState={{ selected: focused }} + > + + + + + + + {meta.label} + + ); +} + const styles = StyleSheet.create({ wrap: { backgroundColor: colors.bgSecondary, @@ -78,6 +169,18 @@ const styles = StyleSheet.create({ borderTopColor: colors.glassBorder, borderTopWidth: StyleSheet.hairlineWidth, }, + indicator: { + position: 'absolute', + top: 0, + left: 0, + alignItems: 'center', + }, + indicatorBar: { + width: 28, + height: 3, + borderRadius: 999, + backgroundColor: colors.accent, + }, tab: { flex: 1, alignItems: 'center', @@ -87,6 +190,7 @@ const styles = StyleSheet.create({ label: { marginTop: 2, fontSize: 10, + fontFamily: fonts.sans.regular, }, });