feat: add glass UI component library
GlassCard, GlassButton (with pulse animation), GlassSlider (gesture-based), GlassBottomSheet (3-state snap), GlassPill selector, and GlassTabBar with blur backing.
This commit is contained in:
116
src/components/glass/GlassBottomSheet.tsx
Normal file
116
src/components/glass/GlassBottomSheet.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Dimensions, StyleSheet, View } from "react-native";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withSpring,
|
||||||
|
clamp,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { colors, glassRadius, layout } from "@/theme";
|
||||||
|
|
||||||
|
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
||||||
|
|
||||||
|
type SheetState = "peek" | "half" | "full";
|
||||||
|
|
||||||
|
const SNAP_POINTS: Record<SheetState, number> = {
|
||||||
|
peek: SCREEN_HEIGHT - layout.bottomSheetPeek,
|
||||||
|
half: SCREEN_HEIGHT - layout.bottomSheetHalf,
|
||||||
|
full: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GlassBottomSheetProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
initialState?: SheetState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassBottomSheet({
|
||||||
|
children,
|
||||||
|
initialState = "peek",
|
||||||
|
}: GlassBottomSheetProps) {
|
||||||
|
const translateY = useSharedValue(SNAP_POINTS[initialState]);
|
||||||
|
const startY = useSharedValue(0);
|
||||||
|
|
||||||
|
const findNearestSnap = (y: number): number => {
|
||||||
|
const points = Object.values(SNAP_POINTS);
|
||||||
|
return points.reduce((prev, curr) =>
|
||||||
|
Math.abs(curr - y) < Math.abs(prev - y) ? curr : prev
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pan = Gesture.Pan()
|
||||||
|
.onStart(() => {
|
||||||
|
startY.value = translateY.value;
|
||||||
|
})
|
||||||
|
.onChange((e) => {
|
||||||
|
translateY.value = clamp(
|
||||||
|
startY.value + e.translationY,
|
||||||
|
SNAP_POINTS.full,
|
||||||
|
SNAP_POINTS.peek
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.onEnd((e) => {
|
||||||
|
const projected = translateY.value + e.velocityY * 0.1;
|
||||||
|
const snap = findNearestSnap(projected);
|
||||||
|
translateY.value = withSpring(snap, {
|
||||||
|
damping: 25,
|
||||||
|
stiffness: 200,
|
||||||
|
velocity: e.velocityY,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureDetector gesture={pan}>
|
||||||
|
<Animated.View style={[styles.container, animatedStyle]}>
|
||||||
|
<BlurView intensity={40} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
<View style={styles.overlay} />
|
||||||
|
{/* Handle bar */}
|
||||||
|
<View style={styles.handleContainer}>
|
||||||
|
<View style={styles.handle} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.content}>{children}</View>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: SCREEN_HEIGHT,
|
||||||
|
borderTopLeftRadius: glassRadius.xl,
|
||||||
|
borderTopRightRadius: glassRadius.xl,
|
||||||
|
overflow: "hidden",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFill,
|
||||||
|
backgroundColor: "rgba(12, 12, 20, 0.60)",
|
||||||
|
},
|
||||||
|
handleContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
handle: {
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.25)",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
139
src/components/glass/GlassButton.tsx
Normal file
139
src/components/glass/GlassButton.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, ViewStyle, ActivityIndicator } from "react-native";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withSpring,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { colors, glassRadius, typography } from "@/theme";
|
||||||
|
|
||||||
|
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||||
|
|
||||||
|
interface GlassButtonProps {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
variant?: "primary" | "secondary" | "ghost";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
pulse?: boolean;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassButton({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
icon,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
pulse = false,
|
||||||
|
style,
|
||||||
|
}: GlassButtonProps) {
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
// Subtle pulse animation for CTA
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (pulse && !disabled) {
|
||||||
|
opacity.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(0.85, { duration: 1500 }),
|
||||||
|
withTiming(1, { duration: 1500 })
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [pulse, disabled]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
opacity: disabled ? 0.4 : opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
scale.value = withSpring(0.96, { damping: 15 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressOut = () => {
|
||||||
|
scale.value = withSpring(1, { damping: 15 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (disabled || loading) return;
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
onPress();
|
||||||
|
};
|
||||||
|
|
||||||
|
const heights = { sm: 36, md: 48, lg: 56 };
|
||||||
|
const fontSizes = { sm: 13, md: 15, lg: 17 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedPressable
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
height: heights[size],
|
||||||
|
borderRadius: glassRadius.pill,
|
||||||
|
borderColor:
|
||||||
|
variant === "primary"
|
||||||
|
? colors.glass.borderLight
|
||||||
|
: colors.glass.border,
|
||||||
|
backgroundColor:
|
||||||
|
variant === "primary"
|
||||||
|
? "rgba(255, 255, 255, 0.12)"
|
||||||
|
: variant === "secondary"
|
||||||
|
? "rgba(255, 255, 255, 0.06)"
|
||||||
|
: "transparent",
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
animatedStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={variant === "ghost" ? 0 : 20}
|
||||||
|
tint="dark"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={colors.text.primary} size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{icon}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
typography.button,
|
||||||
|
{ fontSize: fontSizes[size] },
|
||||||
|
icon != null && { marginLeft: 8 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatedPressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
});
|
||||||
67
src/components/glass/GlassCard.tsx
Normal file
67
src/components/glass/GlassCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, View, ViewStyle } from "react-native";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { glassSurface, glassIntensity, glassRadius } from "@/theme";
|
||||||
|
import type { GlassVariant } from "@/theme";
|
||||||
|
|
||||||
|
interface GlassCardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: GlassVariant;
|
||||||
|
radius?: keyof typeof glassRadius;
|
||||||
|
padding?: number;
|
||||||
|
style?: ViewStyle;
|
||||||
|
noPadding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassCard({
|
||||||
|
children,
|
||||||
|
variant = "medium",
|
||||||
|
radius = "lg",
|
||||||
|
padding = 16,
|
||||||
|
style,
|
||||||
|
noPadding = false,
|
||||||
|
}: GlassCardProps) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
glassSurface(variant),
|
||||||
|
{ borderRadius: glassRadius[radius] },
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={glassIntensity[variant]}
|
||||||
|
tint="dark"
|
||||||
|
style={[StyleSheet.absoluteFill, { borderRadius: glassRadius[radius] }]}
|
||||||
|
/>
|
||||||
|
{/* Top edge highlight for depth illusion */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.highlight,
|
||||||
|
{ borderTopLeftRadius: glassRadius[radius], borderTopRightRadius: glassRadius[radius] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={[styles.content, !noPadding && { padding }]}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
highlight: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
89
src/components/glass/GlassPill.tsx
Normal file
89
src/components/glass/GlassPill.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, ViewStyle } from "react-native";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withSpring,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { colors, glassRadius, typography } from "@/theme";
|
||||||
|
|
||||||
|
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||||
|
|
||||||
|
interface GlassPillProps {
|
||||||
|
label: string;
|
||||||
|
selected?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassPill({ label, selected = false, onPress, style }: GlassPillProps) {
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
scale.value = withSpring(0.92, { damping: 12 });
|
||||||
|
setTimeout(() => {
|
||||||
|
scale.value = withSpring(1, { damping: 12 });
|
||||||
|
}, 100);
|
||||||
|
onPress();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedPressable
|
||||||
|
onPress={handlePress}
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
selected && styles.selected,
|
||||||
|
style,
|
||||||
|
animatedStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={selected ? 30 : 15}
|
||||||
|
tint="dark"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.label,
|
||||||
|
selected && styles.labelSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</AnimatedPressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: glassRadius.pill,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.04)",
|
||||||
|
overflow: "hidden",
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
borderColor: colors.glass.borderLight,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.12)",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
...typography.bodySecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
labelSelected: {
|
||||||
|
color: colors.text.primary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
149
src/components/glass/GlassSlider.tsx
Normal file
149
src/components/glass/GlassSlider.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
runOnJS,
|
||||||
|
withSpring,
|
||||||
|
clamp,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { colors, typography } from "@/theme";
|
||||||
|
|
||||||
|
interface GlassSliderProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
onValueChange: (value: number) => void;
|
||||||
|
formatValue?: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassSlider({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 1,
|
||||||
|
step = 0.01,
|
||||||
|
onValueChange,
|
||||||
|
formatValue,
|
||||||
|
}: GlassSliderProps) {
|
||||||
|
const trackWidth = useSharedValue(0);
|
||||||
|
const thumbScale = useSharedValue(1);
|
||||||
|
const progress = (value - min) / (max - min);
|
||||||
|
|
||||||
|
const snap = (v: number) => {
|
||||||
|
const stepped = Math.round(v / step) * step;
|
||||||
|
return Math.round(stepped * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pan = Gesture.Pan()
|
||||||
|
.onBegin(() => {
|
||||||
|
thumbScale.value = withSpring(1.3, { damping: 12 });
|
||||||
|
})
|
||||||
|
.onChange((e) => {
|
||||||
|
const normalized = clamp(
|
||||||
|
(value - min) / (max - min) + e.changeX / trackWidth.value,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const newValue = snap(min + normalized * (max - min));
|
||||||
|
runOnJS(onValueChange)(newValue);
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
thumbScale.value = withSpring(1, { damping: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const thumbAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: thumbScale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const displayValue = formatValue ? formatValue(value) : `${Math.round(value * 100)}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.labelRow}>
|
||||||
|
<Text style={typography.bodySecondary}>{label}</Text>
|
||||||
|
<Text style={typography.bodySecondary}>{displayValue}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={styles.track}
|
||||||
|
onLayout={(e) => {
|
||||||
|
trackWidth.value = e.nativeEvent.layout.width;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Track background */}
|
||||||
|
<View style={styles.trackBg} />
|
||||||
|
{/* Filled portion */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.trackFill,
|
||||||
|
{ width: `${progress * 100}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{/* Thumb */}
|
||||||
|
<GestureDetector gesture={pan}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.thumb,
|
||||||
|
{ left: `${progress * 100}%` },
|
||||||
|
thumbAnimatedStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.thumbInner} />
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
height: 32,
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
trackBg: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
|
},
|
||||||
|
trackFill: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.30)",
|
||||||
|
},
|
||||||
|
thumb: {
|
||||||
|
position: "absolute",
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
marginLeft: -12,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.40)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
thumbInner: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.text.primary,
|
||||||
|
},
|
||||||
|
});
|
||||||
143
src/components/glass/GlassTabBar.tsx
Normal file
143
src/components/glass/GlassTabBar.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, Text, View, Pressable } from "react-native";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { BottomTabBarProps } from "@react-navigation/bottom-tabs";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withSpring,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { colors, glassRadius, layout } from "@/theme";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||||
|
|
||||||
|
export function GlassTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { paddingBottom: insets.bottom || 12 }]}>
|
||||||
|
<BlurView intensity={40} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
<View style={styles.overlay} />
|
||||||
|
<View style={styles.tabs}>
|
||||||
|
{state.routes.map((route: typeof state.routes[number], index: number) => {
|
||||||
|
const { options } = descriptors[route.key];
|
||||||
|
const label = (options.tabBarLabel ?? options.title ?? route.name) as string;
|
||||||
|
const isFocused = state.index === index;
|
||||||
|
|
||||||
|
const onPress = () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
const event = navigation.emit({
|
||||||
|
type: "tabPress",
|
||||||
|
target: route.key,
|
||||||
|
canPreventDefault: true,
|
||||||
|
});
|
||||||
|
if (!isFocused && !event.defaultPrevented) {
|
||||||
|
navigation.navigate(route.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabItem
|
||||||
|
key={route.key}
|
||||||
|
label={label}
|
||||||
|
focused={isFocused}
|
||||||
|
icon={options.tabBarIcon}
|
||||||
|
onPress={onPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabItem({
|
||||||
|
label,
|
||||||
|
focused,
|
||||||
|
icon,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
focused: boolean;
|
||||||
|
icon: any;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedPressable
|
||||||
|
onPress={onPress}
|
||||||
|
onPressIn={() => {
|
||||||
|
scale.value = withSpring(0.9, { damping: 12 });
|
||||||
|
}}
|
||||||
|
onPressOut={() => {
|
||||||
|
scale.value = withSpring(1, { damping: 12 });
|
||||||
|
}}
|
||||||
|
style={[styles.tab, animatedStyle]}
|
||||||
|
>
|
||||||
|
{icon?.({
|
||||||
|
color: focused ? colors.text.primary : colors.text.tertiary,
|
||||||
|
size: 22,
|
||||||
|
focused,
|
||||||
|
})}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabLabel,
|
||||||
|
{ color: focused ? colors.text.primary : colors.text.tertiary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{focused && <View style={styles.indicator} />}
|
||||||
|
</AnimatedPressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
borderTopLeftRadius: glassRadius.xl,
|
||||||
|
borderTopRightRadius: glassRadius.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFill,
|
||||||
|
backgroundColor: "rgba(8, 8, 16, 0.65)",
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
tabLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "500",
|
||||||
|
marginTop: 4,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
indicator: {
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: colors.text.primary,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
6
src/components/glass/index.ts
Normal file
6
src/components/glass/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { GlassCard } from "./GlassCard";
|
||||||
|
export { GlassButton } from "./GlassButton";
|
||||||
|
export { GlassSlider } from "./GlassSlider";
|
||||||
|
export { GlassBottomSheet } from "./GlassBottomSheet";
|
||||||
|
export { GlassPill } from "./GlassPill";
|
||||||
|
export { GlassTabBar } from "./GlassTabBar";
|
||||||
Reference in New Issue
Block a user