mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 04:59:53 +02:00
minor UI/UX improvement
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
import { Tabs } from 'expo-router';
|
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 { TabBar, type TabItem } from '@/components/TabBar';
|
||||||
import { colors } from '@/theme';
|
import { colors } from '@/theme';
|
||||||
|
|
||||||
@@ -8,6 +11,12 @@ export default function TabsLayout() {
|
|||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
sceneStyle: { backgroundColor: colors.bgPrimary },
|
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 }) => {
|
tabBar={({ state, navigation }) => {
|
||||||
const items: TabItem[] = state.routes.map((route, index) => ({
|
const items: TabItem[] = state.routes.map((route, index) => ({
|
||||||
|
|||||||
+120
-16
@@ -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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
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 { 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;
|
type IconName = keyof typeof Ionicons.glyphMap;
|
||||||
|
|
||||||
@@ -32,7 +39,34 @@ interface TabBarProps {
|
|||||||
*/
|
*/
|
||||||
export function TabBar({ items, onPress }: TabBarProps) {
|
export function TabBar({ items, onPress }: TabBarProps) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const tabs = items.filter((item) => TAB_META[item.name]);
|
||||||
const homeFocused = items.some((item) => item.name === 'index' && item.focused);
|
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 (
|
return (
|
||||||
<View style={styles.wrap}>
|
<View style={styles.wrap}>
|
||||||
@@ -42,25 +76,21 @@ export function TabBar({ items, onPress }: TabBarProps) {
|
|||||||
styles.bar,
|
styles.bar,
|
||||||
{ paddingBottom: insets.bottom, height: layout.tabBarHeight + insets.bottom },
|
{ 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];
|
const meta = TAB_META[item.name];
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
const color = item.focused ? colors.accent : colors.textTertiary;
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<TabButton
|
||||||
key={item.key}
|
key={item.key}
|
||||||
style={styles.tab}
|
meta={meta}
|
||||||
|
focused={item.focused}
|
||||||
onPress={() => onPress(item)}
|
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>
|
</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({
|
const styles = StyleSheet.create({
|
||||||
wrap: {
|
wrap: {
|
||||||
backgroundColor: colors.bgSecondary,
|
backgroundColor: colors.bgSecondary,
|
||||||
@@ -78,6 +169,18 @@ const styles = StyleSheet.create({
|
|||||||
borderTopColor: colors.glassBorder,
|
borderTopColor: colors.glassBorder,
|
||||||
borderTopWidth: StyleSheet.hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
|
indicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
indicatorBar: {
|
||||||
|
width: 28,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
},
|
||||||
tab: {
|
tab: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -87,6 +190,7 @@ const styles = StyleSheet.create({
|
|||||||
label: {
|
label: {
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
fontFamily: fonts.sans.regular,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user