minor UI/UX improvement

This commit is contained in:
Boof2015
2026-06-30 01:00:33 -04:00
parent 19a2e30d0b
commit 8f23c6a1e2
2 changed files with 129 additions and 16 deletions
+9
View File
@@ -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) => ({
+120 -16
View File
@@ -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 (
<View style={styles.wrap}>
@@ -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) => {
<Animated.View style={[styles.indicator, indicatorStyle]} pointerEvents="none">
<View style={styles.indicatorBar} />
</Animated.View>
{tabs.map((item) => {
const meta = TAB_META[item.name];
if (!meta) return null;
const color = item.focused ? colors.accent : colors.textTertiary;
return (
<Pressable
<TabButton
key={item.key}
style={styles.tab}
meta={meta}
focused={item.focused}
onPress={() => onPress(item)}
hitSlop={8}
accessibilityRole="tab"
accessibilityState={{ selected: item.focused }}
>
<Ionicons name={meta.icon} size={22} color={color} />
<Text variant="caption" style={[styles.label, { color }]}>
{meta.label}
</Text>
</Pressable>
/>
);
})}
</View>
@@ -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 (
<Pressable
style={styles.tab}
onPress={onPress}
onPressIn={() => {
press.value = withTiming(1, motion.quick);
}}
onPressOut={() => {
press.value = withTiming(0, motion.quick);
}}
hitSlop={8}
accessibilityRole="tab"
accessibilityState={{ selected: focused }}
>
<Animated.View style={depressStyle}>
<Ionicons name={meta.icon} size={22} color={colors.textTertiary} />
<Animated.View style={[StyleSheet.absoluteFill, accentStyle]}>
<Ionicons name={meta.icon} size={22} color={colors.accent} />
</Animated.View>
</Animated.View>
<Animated.Text style={[styles.label, labelStyle]}>{meta.label}</Animated.Text>
</Pressable>
);
}
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,
},
});