-
38.gitignore
-
50README.md
-
41app.json
-
45app/(tabs)/_layout.tsx
-
109app/(tabs)/explore.tsx
-
74app/(tabs)/index.tsx
-
32app/+not-found.tsx
-
39app/_layout.tsx
-
BINassets/fonts/SpaceMono-Regular.ttf
-
BINassets/images/adaptive-icon.png
-
BINassets/images/favicon.png
-
BINassets/images/icon.png
-
BINassets/images/partial-react-logo.png
-
BINassets/images/react-logo.png
-
BINassets/images/react-logo@2x.png
-
BINassets/images/react-logo@3x.png
-
BINassets/images/splash-icon.png
-
45components/Collapsible.tsx
-
24components/ExternalLink.tsx
-
18components/HapticTab.tsx
-
40components/HelloWave.tsx
-
82components/ParallaxScrollView.tsx
-
60components/ThemedText.tsx
-
14components/ThemedView.tsx
-
10components/__tests__/ThemedText-test.tsx
-
24components/__tests__/__snapshots__/ThemedText-test.tsx.snap
-
32components/ui/IconSymbol.ios.tsx
-
43components/ui/IconSymbol.tsx
-
22components/ui/TabBarBackground.ios.tsx
-
6components/ui/TabBarBackground.tsx
-
26constants/Colors.ts
-
1hooks/useColorScheme.ts
-
21hooks/useColorScheme.web.ts
-
21hooks/useThemeColor.ts
-
14386package-lock.json
-
54package.json
-
112scripts/reset-project.js
-
17tsconfig.json
@ -0,0 +1,38 @@ |
|||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files |
|||
|
|||
# dependencies |
|||
node_modules/ |
|||
|
|||
# Expo |
|||
.expo/ |
|||
dist/ |
|||
web-build/ |
|||
expo-env.d.ts |
|||
|
|||
# Native |
|||
*.orig.* |
|||
*.jks |
|||
*.p8 |
|||
*.p12 |
|||
*.key |
|||
*.mobileprovision |
|||
|
|||
# Metro |
|||
.metro-health-check* |
|||
|
|||
# debug |
|||
npm-debug.* |
|||
yarn-debug.* |
|||
yarn-error.* |
|||
|
|||
# macOS |
|||
.DS_Store |
|||
*.pem |
|||
|
|||
# local env files |
|||
.env*.local |
|||
|
|||
# typescript |
|||
*.tsbuildinfo |
|||
|
|||
app-example |
@ -0,0 +1,50 @@ |
|||
# Welcome to your Expo app 👋 |
|||
|
|||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). |
|||
|
|||
## Get started |
|||
|
|||
1. Install dependencies |
|||
|
|||
```bash |
|||
npm install |
|||
``` |
|||
|
|||
2. Start the app |
|||
|
|||
```bash |
|||
npx expo start |
|||
``` |
|||
|
|||
In the output, you'll find options to open the app in a |
|||
|
|||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/) |
|||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) |
|||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) |
|||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo |
|||
|
|||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). |
|||
|
|||
## Get a fresh project |
|||
|
|||
When you're ready, run: |
|||
|
|||
```bash |
|||
npm run reset-project |
|||
``` |
|||
|
|||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. |
|||
|
|||
## Learn more |
|||
|
|||
To learn more about developing your project with Expo, look at the following resources: |
|||
|
|||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). |
|||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. |
|||
|
|||
## Join the community |
|||
|
|||
Join our community of developers creating universal apps. |
|||
|
|||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. |
|||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. |
@ -0,0 +1,41 @@ |
|||
{ |
|||
"expo": { |
|||
"name": "outline-rn", |
|||
"slug": "outline-rn", |
|||
"version": "1.0.0", |
|||
"orientation": "portrait", |
|||
"icon": "./assets/images/icon.png", |
|||
"scheme": "myapp", |
|||
"userInterfaceStyle": "automatic", |
|||
"newArchEnabled": true, |
|||
"ios": { |
|||
"supportsTablet": true |
|||
}, |
|||
"android": { |
|||
"adaptiveIcon": { |
|||
"foregroundImage": "./assets/images/adaptive-icon.png", |
|||
"backgroundColor": "#ffffff" |
|||
} |
|||
}, |
|||
"web": { |
|||
"bundler": "metro", |
|||
"output": "static", |
|||
"favicon": "./assets/images/favicon.png" |
|||
}, |
|||
"plugins": [ |
|||
"expo-router", |
|||
[ |
|||
"expo-splash-screen", |
|||
{ |
|||
"image": "./assets/images/splash-icon.png", |
|||
"imageWidth": 200, |
|||
"resizeMode": "contain", |
|||
"backgroundColor": "#ffffff" |
|||
} |
|||
] |
|||
], |
|||
"experiments": { |
|||
"typedRoutes": true |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,45 @@ |
|||
import { Tabs } from 'expo-router'; |
|||
import React from 'react'; |
|||
import { Platform } from 'react-native'; |
|||
|
|||
import { HapticTab } from '@/components/HapticTab'; |
|||
import { IconSymbol } from '@/components/ui/IconSymbol'; |
|||
import TabBarBackground from '@/components/ui/TabBarBackground'; |
|||
import { Colors } from '@/constants/Colors'; |
|||
import { useColorScheme } from '@/hooks/useColorScheme'; |
|||
|
|||
export default function TabLayout() { |
|||
const colorScheme = useColorScheme(); |
|||
|
|||
return ( |
|||
<Tabs |
|||
screenOptions={{ |
|||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint, |
|||
headerShown: false, |
|||
tabBarButton: HapticTab, |
|||
tabBarBackground: TabBarBackground, |
|||
tabBarStyle: Platform.select({ |
|||
ios: { |
|||
// Use a transparent background on iOS to show the blur effect
|
|||
position: 'absolute', |
|||
}, |
|||
default: {}, |
|||
}), |
|||
}}> |
|||
<Tabs.Screen |
|||
name="index" |
|||
options={{ |
|||
title: 'Home', |
|||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />, |
|||
}} |
|||
/> |
|||
<Tabs.Screen |
|||
name="explore" |
|||
options={{ |
|||
title: 'Explore', |
|||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />, |
|||
}} |
|||
/> |
|||
</Tabs> |
|||
); |
|||
} |
@ -0,0 +1,109 @@ |
|||
import { StyleSheet, Image, Platform } from 'react-native'; |
|||
|
|||
import { Collapsible } from '@/components/Collapsible'; |
|||
import { ExternalLink } from '@/components/ExternalLink'; |
|||
import ParallaxScrollView from '@/components/ParallaxScrollView'; |
|||
import { ThemedText } from '@/components/ThemedText'; |
|||
import { ThemedView } from '@/components/ThemedView'; |
|||
import { IconSymbol } from '@/components/ui/IconSymbol'; |
|||
|
|||
export default function TabTwoScreen() { |
|||
return ( |
|||
<ParallaxScrollView |
|||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }} |
|||
headerImage={ |
|||
<IconSymbol |
|||
size={310} |
|||
color="#808080" |
|||
name="chevron.left.forwardslash.chevron.right" |
|||
style={styles.headerImage} |
|||
/> |
|||
}> |
|||
<ThemedView style={styles.titleContainer}> |
|||
<ThemedText type="title">Explore</ThemedText> |
|||
</ThemedView> |
|||
<ThemedText>This app includes example code to help you get started.</ThemedText> |
|||
<Collapsible title="File-based routing"> |
|||
<ThemedText> |
|||
This app has two screens:{' '} |
|||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '} |
|||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText> |
|||
</ThemedText> |
|||
<ThemedText> |
|||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '} |
|||
sets up the tab navigator. |
|||
</ThemedText> |
|||
<ExternalLink href="https://docs.expo.dev/router/introduction"> |
|||
<ThemedText type="link">Learn more</ThemedText> |
|||
</ExternalLink> |
|||
</Collapsible> |
|||
<Collapsible title="Android, iOS, and web support"> |
|||
<ThemedText> |
|||
You can open this project on Android, iOS, and the web. To open the web version, press{' '} |
|||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project. |
|||
</ThemedText> |
|||
</Collapsible> |
|||
<Collapsible title="Images"> |
|||
<ThemedText> |
|||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '} |
|||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for |
|||
different screen densities |
|||
</ThemedText> |
|||
<Image source={require('@/assets/images/react-logo.png')} style={{ alignSelf: 'center' }} /> |
|||
<ExternalLink href="https://reactnative.dev/docs/images"> |
|||
<ThemedText type="link">Learn more</ThemedText> |
|||
</ExternalLink> |
|||
</Collapsible> |
|||
<Collapsible title="Custom fonts"> |
|||
<ThemedText> |
|||
Open <ThemedText type="defaultSemiBold">app/_layout.tsx</ThemedText> to see how to load{' '} |
|||
<ThemedText style={{ fontFamily: 'SpaceMono' }}> |
|||
custom fonts such as this one. |
|||
</ThemedText> |
|||
</ThemedText> |
|||
<ExternalLink href="https://docs.expo.dev/versions/latest/sdk/font"> |
|||
<ThemedText type="link">Learn more</ThemedText> |
|||
</ExternalLink> |
|||
</Collapsible> |
|||
<Collapsible title="Light and dark mode components"> |
|||
<ThemedText> |
|||
This template has light and dark mode support. The{' '} |
|||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect |
|||
what the user's current color scheme is, and so you can adjust UI colors accordingly. |
|||
</ThemedText> |
|||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/"> |
|||
<ThemedText type="link">Learn more</ThemedText> |
|||
</ExternalLink> |
|||
</Collapsible> |
|||
<Collapsible title="Animations"> |
|||
<ThemedText> |
|||
This template includes an example of an animated component. The{' '} |
|||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses |
|||
the powerful <ThemedText type="defaultSemiBold">react-native-reanimated</ThemedText>{' '} |
|||
library to create a waving hand animation. |
|||
</ThemedText> |
|||
{Platform.select({ |
|||
ios: ( |
|||
<ThemedText> |
|||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '} |
|||
component provides a parallax effect for the header image. |
|||
</ThemedText> |
|||
), |
|||
})} |
|||
</Collapsible> |
|||
</ParallaxScrollView> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
headerImage: { |
|||
color: '#808080', |
|||
bottom: -90, |
|||
left: -35, |
|||
position: 'absolute', |
|||
}, |
|||
titleContainer: { |
|||
flexDirection: 'row', |
|||
gap: 8, |
|||
}, |
|||
}); |
@ -0,0 +1,74 @@ |
|||
import { Image, StyleSheet, Platform } from 'react-native'; |
|||
|
|||
import { HelloWave } from '@/components/HelloWave'; |
|||
import ParallaxScrollView from '@/components/ParallaxScrollView'; |
|||
import { ThemedText } from '@/components/ThemedText'; |
|||
import { ThemedView } from '@/components/ThemedView'; |
|||
|
|||
export default function HomeScreen() { |
|||
return ( |
|||
<ParallaxScrollView |
|||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }} |
|||
headerImage={ |
|||
<Image |
|||
source={require('@/assets/images/partial-react-logo.png')} |
|||
style={styles.reactLogo} |
|||
/> |
|||
}> |
|||
<ThemedView style={styles.titleContainer}> |
|||
<ThemedText type="title">Welcome!</ThemedText> |
|||
<HelloWave /> |
|||
</ThemedView> |
|||
<ThemedView style={styles.stepContainer}> |
|||
<ThemedText type="subtitle">Step 1: Try it</ThemedText> |
|||
<ThemedText> |
|||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes. |
|||
Press{' '} |
|||
<ThemedText type="defaultSemiBold"> |
|||
{Platform.select({ |
|||
ios: 'cmd + d', |
|||
android: 'cmd + m', |
|||
web: 'F12' |
|||
})} |
|||
</ThemedText>{' '} |
|||
to open developer tools. |
|||
</ThemedText> |
|||
</ThemedView> |
|||
<ThemedView style={styles.stepContainer}> |
|||
<ThemedText type="subtitle">Step 2: Explore</ThemedText> |
|||
<ThemedText> |
|||
Tap the Explore tab to learn more about what's included in this starter app. |
|||
</ThemedText> |
|||
</ThemedView> |
|||
<ThemedView style={styles.stepContainer}> |
|||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText> |
|||
<ThemedText> |
|||
When you're ready, run{' '} |
|||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '} |
|||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '} |
|||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '} |
|||
<ThemedText type="defaultSemiBold">app-example</ThemedText>. |
|||
</ThemedText> |
|||
</ThemedView> |
|||
</ParallaxScrollView> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
titleContainer: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
gap: 8, |
|||
}, |
|||
stepContainer: { |
|||
gap: 8, |
|||
marginBottom: 8, |
|||
}, |
|||
reactLogo: { |
|||
height: 178, |
|||
width: 290, |
|||
bottom: 0, |
|||
left: 0, |
|||
position: 'absolute', |
|||
}, |
|||
}); |
@ -0,0 +1,32 @@ |
|||
import { Link, Stack } from 'expo-router'; |
|||
import { StyleSheet } from 'react-native'; |
|||
|
|||
import { ThemedText } from '@/components/ThemedText'; |
|||
import { ThemedView } from '@/components/ThemedView'; |
|||
|
|||
export default function NotFoundScreen() { |
|||
return ( |
|||
<> |
|||
<Stack.Screen options={{ title: 'Oops!' }} /> |
|||
<ThemedView style={styles.container}> |
|||
<ThemedText type="title">This screen doesn't exist.</ThemedText> |
|||
<Link href="/" style={styles.link}> |
|||
<ThemedText type="link">Go to home screen!</ThemedText> |
|||
</Link> |
|||
</ThemedView> |
|||
</> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
container: { |
|||
flex: 1, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
padding: 20, |
|||
}, |
|||
link: { |
|||
marginTop: 15, |
|||
paddingVertical: 15, |
|||
}, |
|||
}); |
@ -0,0 +1,39 @@ |
|||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; |
|||
import { useFonts } from 'expo-font'; |
|||
import { Stack } from 'expo-router'; |
|||
import * as SplashScreen from 'expo-splash-screen'; |
|||
import { StatusBar } from 'expo-status-bar'; |
|||
import { useEffect } from 'react'; |
|||
import 'react-native-reanimated'; |
|||
|
|||
import { useColorScheme } from '@/hooks/useColorScheme'; |
|||
|
|||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
|||
SplashScreen.preventAutoHideAsync(); |
|||
|
|||
export default function RootLayout() { |
|||
const colorScheme = useColorScheme(); |
|||
const [loaded] = useFonts({ |
|||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
if (loaded) { |
|||
SplashScreen.hideAsync(); |
|||
} |
|||
}, [loaded]); |
|||
|
|||
if (!loaded) { |
|||
return null; |
|||
} |
|||
|
|||
return ( |
|||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> |
|||
<Stack> |
|||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> |
|||
<Stack.Screen name="+not-found" /> |
|||
</Stack> |
|||
<StatusBar style="auto" /> |
|||
</ThemeProvider> |
|||
); |
|||
} |
After Width: 1024 | Height: 1024 | Size: 17 KiB |
After Width: 48 | Height: 48 | Size: 1.4 KiB |
After Width: 1024 | Height: 1024 | Size: 22 KiB |
After Width: 518 | Height: 316 | Size: 5.0 KiB |
After Width: 100 | Height: 100 | Size: 6.2 KiB |
After Width: 200 | Height: 200 | Size: 14 KiB |
After Width: 300 | Height: 300 | Size: 21 KiB |
After Width: 1024 | Height: 1024 | Size: 17 KiB |
@ -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, |
|||
}, |
|||
}); |
@ -0,0 +1,24 @@ |
|||
import { 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: 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); |
|||
} |
|||
}} |
|||
/> |
|||
); |
|||
} |
@ -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); |
|||
}} |
|||
/> |
|||
); |
|||
} |
@ -0,0 +1,40 @@ |
|||
import { useEffect } from 'react'; |
|||
import { StyleSheet } from 'react-native'; |
|||
import Animated, { |
|||
useSharedValue, |
|||
useAnimatedStyle, |
|||
withTiming, |
|||
withRepeat, |
|||
withSequence, |
|||
} 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
|
|||
); |
|||
}, []); |
|||
|
|||
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, |
|||
}, |
|||
}); |
@ -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', |
|||
}, |
|||
}); |
@ -0,0 +1,60 @@ |
|||
import { Text, type TextProps, StyleSheet } 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', |
|||
}, |
|||
}); |
@ -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} />; |
|||
} |
@ -0,0 +1,10 @@ |
|||
import * as React from 'react'; |
|||
import renderer from 'react-test-renderer'; |
|||
|
|||
import { ThemedText } from '../ThemedText'; |
|||
|
|||
it(`renders correctly`, () => { |
|||
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON(); |
|||
|
|||
expect(tree).toMatchSnapshot(); |
|||
}); |
@ -0,0 +1,24 @@ |
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
|||
|
|||
exports[`renders correctly 1`] = ` |
|||
<Text |
|||
style={ |
|||
[ |
|||
{ |
|||
"color": "#11181C", |
|||
}, |
|||
{ |
|||
"fontSize": 16, |
|||
"lineHeight": 24, |
|||
}, |
|||
undefined, |
|||
undefined, |
|||
undefined, |
|||
undefined, |
|||
undefined, |
|||
] |
|||
} |
|||
> |
|||
Snapshot test! |
|||
</Text> |
|||
`; |
@ -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, |
|||
]} |
|||
/> |
|||
); |
|||
} |
@ -0,0 +1,43 @@ |
|||
// This file is a fallback for using MaterialIcons on Android and web.
|
|||
|
|||
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; |
|||
import { SymbolWeight } from 'expo-symbols'; |
|||
import React from 'react'; |
|||
import { OpaqueColorValue, StyleProp, ViewStyle } from 'react-native'; |
|||
|
|||
// Add your SFSymbol to MaterialIcons mappings here.
|
|||
const MAPPING = { |
|||
// See MaterialIcons here: https://icons.expo.fyi
|
|||
// See SF Symbols in the SF Symbols app on Mac.
|
|||
'house.fill': 'home', |
|||
'paperplane.fill': 'send', |
|||
'chevron.left.forwardslash.chevron.right': 'code', |
|||
'chevron.right': 'chevron-right', |
|||
} as Partial< |
|||
Record< |
|||
import('expo-symbols').SymbolViewProps['name'], |
|||
React.ComponentProps<typeof MaterialIcons>['name'] |
|||
> |
|||
>; |
|||
|
|||
export type IconSymbolName = keyof typeof MAPPING; |
|||
|
|||
/** |
|||
* An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage. |
|||
* |
|||
* Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons. |
|||
*/ |
|||
export function IconSymbol({ |
|||
name, |
|||
size = 24, |
|||
color, |
|||
style, |
|||
}: { |
|||
name: IconSymbolName; |
|||
size?: number; |
|||
color: string | OpaqueColorValue; |
|||
style?: StyleProp<ViewStyle>; |
|||
weight?: SymbolWeight; |
|||
}) { |
|||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />; |
|||
} |
@ -0,0 +1,22 @@ |
|||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; |
|||
import { BlurView } from 'expo-blur'; |
|||
import { StyleSheet } from 'react-native'; |
|||
import { useSafeAreaInsets } from 'react-native-safe-area-context'; |
|||
|
|||
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() { |
|||
const tabHeight = useBottomTabBarHeight(); |
|||
const { bottom } = useSafeAreaInsets(); |
|||
return tabHeight - bottom; |
|||
} |
@ -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; |
|||
} |
@ -0,0 +1,26 @@ |
|||
/** |
|||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode. |
|||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
|||
*/ |
|||
|
|||
const tintColorLight = '#0a7ea4'; |
|||
const tintColorDark = '#fff'; |
|||
|
|||
export const Colors = { |
|||
light: { |
|||
text: '#11181C', |
|||
background: '#fff', |
|||
tint: tintColorLight, |
|||
icon: '#687076', |
|||
tabIconDefault: '#687076', |
|||
tabIconSelected: tintColorLight, |
|||
}, |
|||
dark: { |
|||
text: '#ECEDEE', |
|||
background: '#151718', |
|||
tint: tintColorDark, |
|||
icon: '#9BA1A6', |
|||
tabIconDefault: '#9BA1A6', |
|||
tabIconSelected: tintColorDark, |
|||
}, |
|||
}; |
@ -0,0 +1 @@ |
|||
export { useColorScheme } from 'react-native'; |
@ -0,0 +1,21 @@ |
|||
import { useEffect, useState } from 'react'; |
|||
import { useColorScheme as useRNColorScheme } from 'react-native'; |
|||
|
|||
/** |
|||
* To support static rendering, this value needs to be re-calculated on the client side for web |
|||
*/ |
|||
export function useColorScheme() { |
|||
const [hasHydrated, setHasHydrated] = useState(false); |
|||
|
|||
useEffect(() => { |
|||
setHasHydrated(true); |
|||
}, []); |
|||
|
|||
const colorScheme = useRNColorScheme(); |
|||
|
|||
if (hasHydrated) { |
|||
return colorScheme; |
|||
} |
|||
|
|||
return 'light'; |
|||
} |
@ -0,0 +1,21 @@ |
|||
/** |
|||
* Learn more about light and dark modes: |
|||
* https://docs.expo.dev/guides/color-schemes/
|
|||
*/ |
|||
|
|||
import { Colors } from '@/constants/Colors'; |
|||
import { useColorScheme } from '@/hooks/useColorScheme'; |
|||
|
|||
export function useThemeColor( |
|||
props: { light?: string; dark?: string }, |
|||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark |
|||
) { |
|||
const theme = useColorScheme() ?? 'light'; |
|||
const colorFromProps = props[theme]; |
|||
|
|||
if (colorFromProps) { |
|||
return colorFromProps; |
|||
} else { |
|||
return Colors[theme][colorName]; |
|||
} |
|||
} |
14386
package-lock.json
File diff suppressed because it is too large
View File
@ -0,0 +1,54 @@ |
|||
{ |
|||
"name": "outline-rn", |
|||
"main": "expo-router/entry", |
|||
"version": "1.0.0", |
|||
"scripts": { |
|||
"start": "expo start", |
|||
"reset-project": "node ./scripts/reset-project.js", |
|||
"android": "expo start --android", |
|||
"ios": "expo start --ios", |
|||
"web": "expo start --web", |
|||
"test": "jest --watchAll", |
|||
"lint": "expo lint" |
|||
}, |
|||
"jest": { |
|||
"preset": "jest-expo" |
|||
}, |
|||
"dependencies": { |
|||
"@expo/vector-icons": "^14.0.2", |
|||
"@react-navigation/bottom-tabs": "^7.2.0", |
|||
"@react-navigation/native": "^7.0.14", |
|||
"expo": "~52.0.38", |
|||
"expo-blur": "~14.0.3", |
|||
"expo-constants": "~17.0.8", |
|||
"expo-font": "~13.0.4", |
|||
"expo-haptics": "~14.0.1", |
|||
"expo-linking": "~7.0.5", |
|||
"expo-router": "~4.0.18", |
|||
"expo-splash-screen": "~0.29.22", |
|||
"expo-status-bar": "~2.0.1", |
|||
"expo-symbols": "~0.2.2", |
|||
"expo-system-ui": "~4.0.8", |
|||
"expo-web-browser": "~14.0.2", |
|||
"react": "18.3.1", |
|||
"react-dom": "18.3.1", |
|||
"react-native": "0.76.7", |
|||
"react-native-gesture-handler": "~2.20.2", |
|||
"react-native-reanimated": "~3.16.1", |
|||
"react-native-safe-area-context": "4.12.0", |
|||
"react-native-screens": "~4.4.0", |
|||
"react-native-web": "~0.19.13", |
|||
"react-native-webview": "13.12.5" |
|||
}, |
|||
"devDependencies": { |
|||
"@babel/core": "^7.25.2", |
|||
"@types/jest": "^29.5.12", |
|||
"@types/react": "~18.3.12", |
|||
"@types/react-test-renderer": "^18.3.0", |
|||
"jest": "^29.2.1", |
|||
"jest-expo": "~52.0.6", |
|||
"react-test-renderer": "18.3.1", |
|||
"typescript": "^5.3.3" |
|||
}, |
|||
"private": true |
|||
} |
@ -0,0 +1,112 @@ |
|||
#!/usr/bin/env node
|
|||
|
|||
/** |
|||
* This script is used to reset the project to a blank state. |
|||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file. |
|||
* You can remove the `reset-project` script from package.json and safely delete this file after running it. |
|||
*/ |
|||
|
|||
const fs = require("fs"); |
|||
const path = require("path"); |
|||
const readline = require("readline"); |
|||
|
|||
const root = process.cwd(); |
|||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"]; |
|||
const exampleDir = "app-example"; |
|||
const newAppDir = "app"; |
|||
const exampleDirPath = path.join(root, exampleDir); |
|||
|
|||
const indexContent = `import { Text, View } from "react-native";
|
|||
|
|||
export default function Index() { |
|||
return ( |
|||
<View |
|||
style={{ |
|||
flex: 1, |
|||
justifyContent: "center", |
|||
alignItems: "center", |
|||
}} |
|||
> |
|||
<Text>Edit app/index.tsx to edit this screen.</Text> |
|||
</View> |
|||
); |
|||
} |
|||
`;
|
|||
|
|||
const layoutContent = `import { Stack } from "expo-router";
|
|||
|
|||
export default function RootLayout() { |
|||
return <Stack />; |
|||
} |
|||
`;
|
|||
|
|||
const rl = readline.createInterface({ |
|||
input: process.stdin, |
|||
output: process.stdout, |
|||
}); |
|||
|
|||
const moveDirectories = async (userInput) => { |
|||
try { |
|||
if (userInput === "y") { |
|||
// Create the app-example directory
|
|||
await fs.promises.mkdir(exampleDirPath, { recursive: true }); |
|||
console.log(`📁 /${exampleDir} directory created.`); |
|||
} |
|||
|
|||
// Move old directories to new app-example directory or delete them
|
|||
for (const dir of oldDirs) { |
|||
const oldDirPath = path.join(root, dir); |
|||
if (fs.existsSync(oldDirPath)) { |
|||
if (userInput === "y") { |
|||
const newDirPath = path.join(root, exampleDir, dir); |
|||
await fs.promises.rename(oldDirPath, newDirPath); |
|||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`); |
|||
} else { |
|||
await fs.promises.rm(oldDirPath, { recursive: true, force: true }); |
|||
console.log(`❌ /${dir} deleted.`); |
|||
} |
|||
} else { |
|||
console.log(`➡️ /${dir} does not exist, skipping.`); |
|||
} |
|||
} |
|||
|
|||
// Create new /app directory
|
|||
const newAppDirPath = path.join(root, newAppDir); |
|||
await fs.promises.mkdir(newAppDirPath, { recursive: true }); |
|||
console.log("\n📁 New /app directory created."); |
|||
|
|||
// Create index.tsx
|
|||
const indexPath = path.join(newAppDirPath, "index.tsx"); |
|||
await fs.promises.writeFile(indexPath, indexContent); |
|||
console.log("📄 app/index.tsx created."); |
|||
|
|||
// Create _layout.tsx
|
|||
const layoutPath = path.join(newAppDirPath, "_layout.tsx"); |
|||
await fs.promises.writeFile(layoutPath, layoutContent); |
|||
console.log("📄 app/_layout.tsx created."); |
|||
|
|||
console.log("\n✅ Project reset complete. Next steps:"); |
|||
console.log( |
|||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${ |
|||
userInput === "y" |
|||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.` |
|||
: "" |
|||
}`
|
|||
); |
|||
} catch (error) { |
|||
console.error(`❌ Error during script execution: ${error.message}`); |
|||
} |
|||
}; |
|||
|
|||
rl.question( |
|||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ", |
|||
(answer) => { |
|||
const userInput = answer.trim().toLowerCase() || "y"; |
|||
if (userInput === "y" || userInput === "n") { |
|||
moveDirectories(userInput).finally(() => rl.close()); |
|||
} else { |
|||
console.log("❌ Invalid input. Please enter 'Y' or 'N'."); |
|||
rl.close(); |
|||
} |
|||
} |
|||
); |
@ -0,0 +1,17 @@ |
|||
{ |
|||
"extends": "expo/tsconfig.base", |
|||
"compilerOptions": { |
|||
"strict": true, |
|||
"paths": { |
|||
"@/*": [ |
|||
"./*" |
|||
] |
|||
} |
|||
}, |
|||
"include": [ |
|||
"**/*.ts", |
|||
"**/*.tsx", |
|||
".expo/types/**/*.ts", |
|||
"expo-env.d.ts" |
|||
] |
|||
} |