โท Expo ์ฌ์ฉ๋ฒ (sdk 54)
ย
# ์ค์น npx create-expo-app@latest # ์คํ pnpm start # ์บ์ ๋ฆฌ์ # npx expo start -c # android ์คํ a # ios ์คํ i
ย
ย
๐ Routing
ย
ย
Expo๋ Next.js์ฒ๋ผ app/ ํด๋ ๊ธฐ๋ฐ์ ํ์ผ ๋ผ์ฐํ
์ ์ด๋ค.
์ฌ๊ธฐ์ API Routes(์๋ฒ ์ฝ๋) ๊น์ง app/ ์์์ ํจ๊ป ๋ค๋ฃฐ ์ ์๋ค. (Web ์ ์ฉ)
ย
ํ์ผ ์์น | ์ค์ ๊ฒฝ๋ก | ์ค๋ช
|
app/(auth)/login.tsx | /login | (auth) ํด๋ ์ด๋ฆ์ URL์ ํฌํจ๋์ง ์์ |
app/(tabs)/profile.tsx | /profile | ๊ทธ๋ฃน ์ด๋ฆ์ ๋จ์ง ๋ผ์ฐํธ ๊ตฌ์กฐ ์ ๋ฆฌ์ฉ |
app/(tabs)/_layout.tsx | / ๊ธฐ์ค์์ ํญ ๋ค๋น๊ฒ์ด์
์ ๊ตฌ์ฑ | |
app/_layout.tsx | ์ฑ ์ ์ฒด ๋ฃจํธ ๋ค๋น๊ฒ์ด์
|
ย
my-app/ โโ app/ โ โโ _layout.tsx # ์ค์ฒฉ ๋ค๋น๊ฒ์ด์ (Stack/Tabs ๋ฑ) ๋ ์ด์์ โ โโ index.tsx # "/" ๋ฃจํธ ํ๋ฉด โ โโ (marketing)/ # ๋ผ์ฐํธ ๊ทธ๋ฃน(๊ฒฝ๋ก์ ํ์ ์ ๋จ) โ โ โโ landing.tsx # "/landing" โ โโ users/ โ โ โโ [id].tsx # ๋์ ์ธ๊ทธ๋จผํธ => "/users/42" โ โ โโ [id]/settings.tsx # ์ค์ฒฉ ๋์ ๊ฒฝ๋ก โ โโ +not-found.tsx # 404 ์ฒ๋ฆฌ โ โโ hello+api.ts # "/hello" API ๋ผ์ฐํธ (GET/POST ๋ฑ) โ โโ auth/ โ โโ login+api.ts # "/auth/login" API ๋ผ์ฐํธ โโ app.json # web.output=server ์ค์ (์๋ฒ ๋ฒ๋ค) โโ package.json โโ ...
ย
ย
๐ Route Group
ย
๊ดํธ ํด๋: ๊ตฌ์กฐ๋ง, ๊ฒฝ๋ก์ ์ ๋ค์ด๊ฐย
ํ์ผ ์์น | ์ค์ ๊ฒฝ๋ก | ์ค๋ช
|
app/(auth)/login.tsx | /login | (auth) ํด๋ ์ด๋ฆ์ URL์ ํฌํจ๋์ง ์์ |
app/(tabs)/profile.tsx | /profile | ๊ทธ๋ฃน ์ด๋ฆ์ ๋จ์ง ๋ผ์ฐํธ ๊ตฌ์กฐ ์ ๋ฆฌ์ฉ |
app/(tabs)/_layout.tsx | / ๊ธฐ์ค์์ ํญ ๋ค๋น๊ฒ์ด์
์ ๊ตฌ์ฑ | |
app/_layout.tsx | ์ฑ ์ ์ฒด ๋ฃจํธ ๋ค๋น๊ฒ์ด์
|
ย
app/ โโ (auth)/ โ โโ login.tsx โ โโ register.tsx โ โโ _layout.tsx โโ (tabs)/ โ โโ home.tsx โ โโ profile.tsx โ โโ _layout.tsx โโ index.tsx โโ _layout.tsx
ย
ย
๐ Screen Routing (ft. useLocalSearchParams)
ย
ํ๋ฉด ๋ผ์ฐํ
// app/_layout.tsx import { Stack } from 'expo-router'; export default function RootLayout() { return <Stack screenOptions={{ headerShown: false }} />; } // app/index.tsx import { Link } from 'expo-router'; import { View, Text } from 'react-native'; export default function Home() { return ( <View> <Text>Home</Text> <Link href="/users/42">Go user 42</Link> </View> ); } // app/users/[id].tsx import { useLocalSearchParams } from 'expo-router'; export default function User() { const { id } = useLocalSearchParams(); return null; }
ย
ย
๐ modal
ย
ย
app/ โโ _layout.tsx โโ index.tsx โโ modal.tsx
ย
๋ชจ๋ฌ ํ๋ฉด์ ๊ตฌ์ฑ
/* app/_layout.tsx */ import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import 'react-native-reanimated'; import './global.css'; import { useColorScheme } from '@/hooks/use-color-scheme'; /* anchor๋ ๋ฅ๋งํฌ๋ ๋ชจ๋ฌ dismiss ์, ์ฑ์ด ๋์๊ฐ ๊ธฐ์ค ๋ฃจํธ๋ฅผ ์ง์ ํ๋ ์ต์ ์ด๋ค. */ export const unstable_settings = { anchor: '(tabs)', }; export default function RootLayout() { const colorScheme = useColorScheme(); return ( <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <Stack> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} /> </Stack> <StatusBar style="auto" /> </ThemeProvider> ); }
ย
๋ชจ๋ฌ ํ๋ฉด์ผ๋ก ์ด๋
/* app/index.tsx */ import { Link } from 'expo-router'; import { Text, View } from 'react-native'; export default function Home() { return ( <View> <Text>Home screen</Text> <Link href="/modal"> Open modal </Link> </View> ); }
ย
๋ชจ๋ฌ ์ ์ฉ ํ๋ฉด
/* app/modal.tsx */ import { Link } from 'expo-router'; import { StyleSheet } from 'react-native'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; export default function ModalScreen() { return ( <ThemedView style={styles.container}> <ThemedText type="title">This is a modal</ThemedText> <Link href="/" dismissTo 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, }, });
ย
(modal) ํด๋๋ก ๋ฌถ์ด์ ์ฐ๋ ํจํด๋ ์์ฃผ ์ฌ์ฉํจ
app/ _layout.tsx (tabs)/ _layout.tsx index.tsx (modal)/ _layout.tsx // ์ฌ๊ธฐ์ presentation: "modal" ๊ทธ๋ฃน ์ฒ๋ฆฌ settings.tsx
ย
FormSheet ๋ชจ๋ฌ ์ต์
์ค์
import { Stack } from 'expo-router'; import { View, Button } from 'react-native'; function Layout() { return ( <Stack> {/* ๊ธฐ๋ณธ ๋ฉ์ธ ํ๋ฉด(index.tsx) ๋ผ์ฐํธ */} <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ /* ๋ชจ๋ฌ์ ์ด๋ค ์คํ์ผ๋ก ๋์ธ์ง ๊ฒฐ์ (iOS์์ formSheet ํํ๋ก ํ์๋จ) */ presentation: 'formSheet', /* sheet(๋ฐํ ์ํธ)๊ฐ ๊ฐ์ง ์ ์๋ ๋์ด ๋จ๊ณ(detent) ๋ชฉ๋ก (0~1 ๋น์จ) */ sheetAllowedDetents: [0.25, 0.5, 1], /* ์ฒ์ ๋ชจ๋ฌ์ด ์ด๋ฆด ๋ ์์ํ detent ๋จ๊ณ ์ธ๋ฑ์ค (0์ด๋ฉด 0.25๋ถํฐ ์์) */ sheetInitialDetentIndex: 0, /* ์๋จ์ ๋๋๊ทธ ๊ฐ๋ฅํ grabber(์์ก์ด)๋ฅผ ํ์ํ ์ง ์ฌ๋ถ */ sheetGrabberVisible: true, /* sheet ๋ชจ์๋ฆฌ ๋ฅ๊ธ๊ธฐ ๊ฐ (px ๋จ์) */ sheetCornerRadius: 24, /* sheet๊ฐ ํ์ฅ๋ ๋ dim(๋ฐฐ๊ฒฝ ์ด๋์์ง)์ด ์ ์ฉ๋์ง ์๋ ์ต๋ detent ์ธ๋ฑ์ค */ /* ์: 1์ด๋ฉด 0.5 ๋จ๊ณ๊น์ง๋ ๋ฐฐ๊ฒฝ์ด ์ ์ด๋์์ง๊ณ , ๊ทธ ์ด์๋ถํฐ dim ์ ์ฉ */ sheetLargestUndimmedDetentIndex: 1, /* ํธํฐ ์ถ๊ฐ (sheet ํ๋จ์ ๊ณ ์ ๋๋ footer UI ์์ญ) */ unstable_sheetFooter: () => ( <View style={{ padding: 16, backgroundColor: 'white' }}> <Button title="Confirm" onPress={() => {}} /> </View> ), }} /> </Stack> ); } export default Layout;
ย
ย
๐ API Routes (Web ์ ์ฉ)
ย
ย
Web(Server output)์์๋ง ๋์ํ๋ ์๋ฒ ์๋ํฌ์ธํธ์ด๋ค.
API Routes(์๋ฒ ์๋ํฌ์ธํธ): appํด๋ ์์์ ์๋ฒ ์ฝ๋
app/ ํด๋ ์์์ +api.ts ํ์ฅ์ ํ์ผ์ ๋ง๋ค๋ฉด ๊ทธ ๊ฒฝ๋ก๊ฐ ์๋ฒ ์๋ํฌ์ธํธ๊ฐ ๋๋ค. (Next.js app/**/route.ts์ ๋น์ทํ ๊ฐ๋
)
ย
// app/hello+api.ts -> GET /hello export async function GET(request: Request) { return Response.json({ hello: 'world' }); } // ๋์ ยท์ค์ฒฉ๋ ๋์ผ ๊ท์น // app/users/[id]/profile+api.ts -> /users/:id/profile export async function POST(request: Request) { const body = await request.json(); return Response.json({ ok: true, body }); }
ย
API Routes๋ฅผ ์ํ ์๋ฒ ๋ฒ๋ค๊ธฐ
/* app.json */ { "expo": { "web": { "output": "server" } } }
ย
ย
๐ nativewind v5
ย
ย
Working package.json Dependencies
{ "main": "expo-router/entry", "dependencies": { "expo": "~54.0.33", "nativewind": "^5.0.0", "react-native-css": "latest", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-worklets": "0.5.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", }, "pnpm": { "overrides": { "lightningcss": "1.30.1" } }, "private": true }
ย
babel.config.js
module.exports = function (api) { api.cache(true); return { presets: ['babel-preset-expo'], plugins: [ 'react-native-reanimated/plugin', ], }; };
ย
metro.config.ts
// Learn more <https://docs.expo.io/guides/customizing-metro> const { getDefaultConfig } = require('expo/metro-config'); const { withNativewind } = require('nativewind/metro'); /** @type {import('expo/metro-config').MetroConfig} */ const config = getDefaultConfig(__dirname); module.exports = withNativewind(config);
ย
postcss.config.mjs
export default { plugins: { '@tailwindcss/postcss': {}, }, };
ย
nativewind-env.d.ts
// @ts-ignore /// <reference types="react-native-css/types" />
ย
global.css
@import 'tailwindcss/theme.css' layer(theme); @import 'tailwindcss/preflight.css' layer(base); @import 'tailwindcss/utilities.css'; @import 'nativewind/theme';
ย
app/_layout.tsx
import '../global.css';
ย
ย
ย
๐ svg๋ฅผ ์ปดํฌ๋ํธ๋ก ์ฌ์ฉํ๊ธฐ
ย
ย
svg ํ์ผ์ React ์ปดํฌ๋ํธ์ฒ๋ผ importํ์ฌ ์ฌ์ฉํ ์ ์๋๋ก ์ค์
ย
# ์ค์น pnpm add react-native-svg pnpm add -D react-native-svg-transformer # nativewind ์ต์ ๋ฒ์ ๊ณผ ์ถฉ๋ โ 5.0.0-preview.2๋ก ๊ณ ์ # pnpm i nativewind@5.0.0-preview.2
ย
metro.config.js
// Learn more <https://docs.expo.io/guides/customizing-metro> const { getDefaultConfig } = require('expo/metro-config'); const { withNativewind } = require('nativewind/metro'); /** @type {import('expo/metro-config').MetroConfig} */ const config = getDefaultConfig(__dirname); config.transformer.babelTransformerPath = require.resolve( "react-native-svg-transformer" ); config.resolver.assetExts = config.resolver.assetExts.filter( (ext) => ext !== "svg" ); config.resolver.sourceExts.push("svg"); module.exports = withNativewind(config);
ย
global.d.ts
declare module "*.svg" { import * as React from "react"; import { SvgProps } from "react-native-svg"; const content: React.FC<SvgProps>; export default content; }
ย
ย
๐ย font
ย
ย
app.json
"plugins": [ "expo-router", ... [ "expo-font", { "fonts": [ "./assets/fonts/PretendardVariable.ttf" ] } ] ],
ย
์ฌ์ฉํ๋ ๊ณณ
/* components/ui/text.tsx */ import { Text as RNText, TextProps } from "react-native"; export function Text({ style, ...props }: TextProps) { return ( <RNText {...props} style={[{ fontFamily: "PretendardVariable" }, style]} /> ); }
ย
useFonts ํ
์ผ๋ก ๋ก๋๊ฐ ๋์๋์ง ํ์ธ๋ ๊ฐ๋ฅํ๋ค.
const [loaded, error] = useFonts({ 'Inter-Black': require('./assets/fonts/Inter-Black.otf'), });