diff options
author | polwex <polwex@sortug.com> | 2025-07-15 17:20:58 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-07-15 17:20:58 +0700 |
commit | a528bd94a6e8e25010ae26a305550b211df0ddc6 (patch) | |
tree | 887425ddc3160ae023292dfefc49d77c2eb8dcec /components |
Initial commit
Generated by create-expo 3.4.3.
Diffstat (limited to 'components')
-rw-r--r-- | components/Collapsible.tsx | 45 | ||||
-rw-r--r-- | components/ExternalLink.tsx | 24 | ||||
-rw-r--r-- | components/HapticTab.tsx | 18 | ||||
-rw-r--r-- | components/HelloWave.tsx | 40 | ||||
-rw-r--r-- | components/ParallaxScrollView.tsx | 82 | ||||
-rw-r--r-- | components/ThemedText.tsx | 60 | ||||
-rw-r--r-- | components/ThemedView.tsx | 14 | ||||
-rw-r--r-- | components/ui/IconSymbol.ios.tsx | 32 | ||||
-rw-r--r-- | components/ui/IconSymbol.tsx | 41 | ||||
-rw-r--r-- | components/ui/TabBarBackground.ios.tsx | 19 | ||||
-rw-r--r-- | components/ui/TabBarBackground.tsx | 6 |
11 files changed, 381 insertions, 0 deletions
diff --git a/components/Collapsible.tsx b/components/Collapsible.tsx new file mode 100644 index 0000000..55bff2f --- /dev/null +++ b/components/Collapsible.tsx @@ -0,0 +1,45 @@ +import { PropsWithChildren, useState } from 'react'; +import { StyleSheet, TouchableOpacity } from 'react-native'; + +import { ThemedText } from '@/components/ThemedText'; +import { ThemedView } from '@/components/ThemedView'; +import { IconSymbol } from '@/components/ui/IconSymbol'; +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; + +export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { + const [isOpen, setIsOpen] = useState(false); + const theme = useColorScheme() ?? 'light'; + + return ( + <ThemedView> + <TouchableOpacity + style={styles.heading} + onPress={() => setIsOpen((value) => !value)} + activeOpacity={0.8}> + <IconSymbol + name="chevron.right" + size={18} + weight="medium" + color={theme === 'light' ? Colors.light.icon : Colors.dark.icon} + style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }} + /> + + <ThemedText type="defaultSemiBold">{title}</ThemedText> + </TouchableOpacity> + {isOpen && <ThemedView style={styles.content}>{children}</ThemedView>} + </ThemedView> + ); +} + +const styles = StyleSheet.create({ + heading: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + content: { + marginTop: 6, + marginLeft: 24, + }, +}); diff --git a/components/ExternalLink.tsx b/components/ExternalLink.tsx new file mode 100644 index 0000000..dfbd23e --- /dev/null +++ b/components/ExternalLink.tsx @@ -0,0 +1,24 @@ +import { Href, Link } from 'expo-router'; +import { openBrowserAsync } from 'expo-web-browser'; +import { type ComponentProps } from 'react'; +import { Platform } from 'react-native'; + +type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string }; + +export function ExternalLink({ href, ...rest }: Props) { + return ( + <Link + target="_blank" + {...rest} + href={href} + onPress={async (event) => { + if (Platform.OS !== 'web') { + // Prevent the default behavior of linking to the default browser on native. + event.preventDefault(); + // Open the link in an in-app browser. + await openBrowserAsync(href); + } + }} + /> + ); +} diff --git a/components/HapticTab.tsx b/components/HapticTab.tsx new file mode 100644 index 0000000..7f3981c --- /dev/null +++ b/components/HapticTab.tsx @@ -0,0 +1,18 @@ +import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'; +import { PlatformPressable } from '@react-navigation/elements'; +import * as Haptics from 'expo-haptics'; + +export function HapticTab(props: BottomTabBarButtonProps) { + return ( + <PlatformPressable + {...props} + onPressIn={(ev) => { + if (process.env.EXPO_OS === 'ios') { + // Add a soft haptic feedback when pressing down on the tabs. + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + props.onPressIn?.(ev); + }} + /> + ); +} diff --git a/components/HelloWave.tsx b/components/HelloWave.tsx new file mode 100644 index 0000000..eb6ea61 --- /dev/null +++ b/components/HelloWave.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; + +import { ThemedText } from '@/components/ThemedText'; + +export function HelloWave() { + const rotationAnimation = useSharedValue(0); + + useEffect(() => { + rotationAnimation.value = withRepeat( + withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })), + 4 // Run the animation 4 times + ); + }, [rotationAnimation]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotationAnimation.value}deg` }], + })); + + return ( + <Animated.View style={animatedStyle}> + <ThemedText style={styles.text}>👋</ThemedText> + </Animated.View> + ); +} + +const styles = StyleSheet.create({ + text: { + fontSize: 28, + lineHeight: 32, + marginTop: -6, + }, +}); diff --git a/components/ParallaxScrollView.tsx b/components/ParallaxScrollView.tsx new file mode 100644 index 0000000..5df1d75 --- /dev/null +++ b/components/ParallaxScrollView.tsx @@ -0,0 +1,82 @@ +import type { PropsWithChildren, ReactElement } from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { + interpolate, + useAnimatedRef, + useAnimatedStyle, + useScrollViewOffset, +} from 'react-native-reanimated'; + +import { ThemedView } from '@/components/ThemedView'; +import { useBottomTabOverflow } from '@/components/ui/TabBarBackground'; +import { useColorScheme } from '@/hooks/useColorScheme'; + +const HEADER_HEIGHT = 250; + +type Props = PropsWithChildren<{ + headerImage: ReactElement; + headerBackgroundColor: { dark: string; light: string }; +}>; + +export default function ParallaxScrollView({ + children, + headerImage, + headerBackgroundColor, +}: Props) { + const colorScheme = useColorScheme() ?? 'light'; + const scrollRef = useAnimatedRef<Animated.ScrollView>(); + const scrollOffset = useScrollViewOffset(scrollRef); + const bottom = useBottomTabOverflow(); + const headerAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: interpolate( + scrollOffset.value, + [-HEADER_HEIGHT, 0, HEADER_HEIGHT], + [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] + ), + }, + { + scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), + }, + ], + }; + }); + + return ( + <ThemedView style={styles.container}> + <Animated.ScrollView + ref={scrollRef} + scrollEventThrottle={16} + scrollIndicatorInsets={{ bottom }} + contentContainerStyle={{ paddingBottom: bottom }}> + <Animated.View + style={[ + styles.header, + { backgroundColor: headerBackgroundColor[colorScheme] }, + headerAnimatedStyle, + ]}> + {headerImage} + </Animated.View> + <ThemedView style={styles.content}>{children}</ThemedView> + </Animated.ScrollView> + </ThemedView> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + height: HEADER_HEIGHT, + overflow: 'hidden', + }, + content: { + flex: 1, + padding: 32, + gap: 16, + overflow: 'hidden', + }, +}); diff --git a/components/ThemedText.tsx b/components/ThemedText.tsx new file mode 100644 index 0000000..9d214a2 --- /dev/null +++ b/components/ThemedText.tsx @@ -0,0 +1,60 @@ +import { StyleSheet, Text, type TextProps } from 'react-native'; + +import { useThemeColor } from '@/hooks/useThemeColor'; + +export type ThemedTextProps = TextProps & { + lightColor?: string; + darkColor?: string; + type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; +}; + +export function ThemedText({ + style, + lightColor, + darkColor, + type = 'default', + ...rest +}: ThemedTextProps) { + const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); + + return ( + <Text + style={[ + { color }, + type === 'default' ? styles.default : undefined, + type === 'title' ? styles.title : undefined, + type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined, + type === 'subtitle' ? styles.subtitle : undefined, + type === 'link' ? styles.link : undefined, + style, + ]} + {...rest} + /> + ); +} + +const styles = StyleSheet.create({ + default: { + fontSize: 16, + lineHeight: 24, + }, + defaultSemiBold: { + fontSize: 16, + lineHeight: 24, + fontWeight: '600', + }, + title: { + fontSize: 32, + fontWeight: 'bold', + lineHeight: 32, + }, + subtitle: { + fontSize: 20, + fontWeight: 'bold', + }, + link: { + lineHeight: 30, + fontSize: 16, + color: '#0a7ea4', + }, +}); diff --git a/components/ThemedView.tsx b/components/ThemedView.tsx new file mode 100644 index 0000000..4d2cb09 --- /dev/null +++ b/components/ThemedView.tsx @@ -0,0 +1,14 @@ +import { View, type ViewProps } from 'react-native'; + +import { useThemeColor } from '@/hooks/useThemeColor'; + +export type ThemedViewProps = ViewProps & { + lightColor?: string; + darkColor?: string; +}; + +export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { + const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); + + return <View style={[{ backgroundColor }, style]} {...otherProps} />; +} diff --git a/components/ui/IconSymbol.ios.tsx b/components/ui/IconSymbol.ios.tsx new file mode 100644 index 0000000..9177f4d --- /dev/null +++ b/components/ui/IconSymbol.ios.tsx @@ -0,0 +1,32 @@ +import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols'; +import { StyleProp, ViewStyle } from 'react-native'; + +export function IconSymbol({ + name, + size = 24, + color, + style, + weight = 'regular', +}: { + name: SymbolViewProps['name']; + size?: number; + color: string; + style?: StyleProp<ViewStyle>; + weight?: SymbolWeight; +}) { + return ( + <SymbolView + weight={weight} + tintColor={color} + resizeMode="scaleAspectFit" + name={name} + style={[ + { + width: size, + height: size, + }, + style, + ]} + /> + ); +} diff --git a/components/ui/IconSymbol.tsx b/components/ui/IconSymbol.tsx new file mode 100644 index 0000000..b7ece6b --- /dev/null +++ b/components/ui/IconSymbol.tsx @@ -0,0 +1,41 @@ +// Fallback for using MaterialIcons on Android and web. + +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { SymbolWeight, SymbolViewProps } from 'expo-symbols'; +import { ComponentProps } from 'react'; +import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native'; + +type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>; +type IconSymbolName = keyof typeof MAPPING; + +/** + * Add your SF Symbols to Material Icons mappings here. + * - see Material Icons in the [Icons Directory](https://icons.expo.fyi). + * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app. + */ +const MAPPING = { + 'house.fill': 'home', + 'paperplane.fill': 'send', + 'chevron.left.forwardslash.chevron.right': 'code', + 'chevron.right': 'chevron-right', +} as IconMapping; + +/** + * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web. + * This ensures a consistent look across platforms, and optimal resource usage. + * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons. + */ +export function IconSymbol({ + name, + size = 24, + color, + style, +}: { + name: IconSymbolName; + size?: number; + color: string | OpaqueColorValue; + style?: StyleProp<TextStyle>; + weight?: SymbolWeight; +}) { + return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />; +} diff --git a/components/ui/TabBarBackground.ios.tsx b/components/ui/TabBarBackground.ios.tsx new file mode 100644 index 0000000..495b2d4 --- /dev/null +++ b/components/ui/TabBarBackground.ios.tsx @@ -0,0 +1,19 @@ +import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; +import { BlurView } from 'expo-blur'; +import { StyleSheet } from 'react-native'; + +export default function BlurTabBarBackground() { + return ( + <BlurView + // System chrome material automatically adapts to the system's theme + // and matches the native tab bar appearance on iOS. + tint="systemChromeMaterial" + intensity={100} + style={StyleSheet.absoluteFill} + /> + ); +} + +export function useBottomTabOverflow() { + return useBottomTabBarHeight(); +} diff --git a/components/ui/TabBarBackground.tsx b/components/ui/TabBarBackground.tsx new file mode 100644 index 0000000..70d1c3c --- /dev/null +++ b/components/ui/TabBarBackground.tsx @@ -0,0 +1,6 @@ +// This is a shim for web and Android where the tab bar is generally opaque. +export default undefined; + +export function useBottomTabOverflow() { + return 0; +} |