Hello World RN
Link & Pressable
Route Groups
Stack
Tabs
Dynamic Route
Dark Mode
Project
.
.
지난 장에서 공개한 Expo-NativeWind 스타터 깃 은 React Native 와 Expo, Tailwind 를 사용할 수 있도록 사전 세팅된 작업환경을 간단하게 생성할 수 있어서 편리합니다.
하지만, 템플릿의 기본 앱 화면은 우리가 만들고 싶은 앱과는 다른 구조일 수 있죠.
앱의 템플릿을 제거하고, 백지상태에서 Hello World 부터 시작해 봅시다.
먼저, /app 디렉토리의 모든 파일과 폴더들을 제거합니다.
그리고, /app/index.tsx
파일을 생성합니다.
참고로, Expo Router 가 앱을 렌더링하기 위해 파일 모듈을 찾고 렌더링에 반영해가는 순서는 아래와 같습니다.
/app/_layout.tsx
- Nested Layout 적용/app/index.tsx
- App 페이지자식 컴포넌트
NextJS 에 익숙한 분들께는 매우 낯이 익은 구조죠? Expo-Router 의 파일 기반 라우팅은 실제로 NextJS App Router 와 많이 닮아있습니다. NextJS 의 /layout.tsx
대신에 /_layout.tsx
가 사용되고, /page.tsx
대신에 /index.tsx
가 사용된다는 것만 기억해두시면 되겠습니다.
Expo-Router 에서는, 먼저 /_layout.tsx
의 레이아웃이 렌더링 되고, 이후 다음 순서의 라우팅 요소들이 차례로 렌더링 됩니다.
.
.
Hello World RN
그럼 이제부터 RN 에서의 코딩을 시작해보죠.
.
rnfe 코드 스니펫
rnfe
1장에서 진행한 VSCode 익스텐션을 설치하셨다면, RN 스니펫을 사용할 수 있습니다.
VSCode 에서 /app/index.tsx 파일을 생성하고, 빈 페이지에 ‘rnfe’ 를 입력한 후 탭 키를 눌러줍니다.
import { View, Text } from 'react-native'
import React from 'react'
const index = () => {
return (
<View>
<Text>index</Text>
</View>
)
}
export default index
rnfe 스니펫이 React Native 의 기본 코드를 생성해줍니다.
스니펫의 간단규칙은:
r : react
n : native
a : arrow
f : function
c : component
e : export
React 프로젝트에서는 arrow 가 옵션이지만, RN 프로젝트에서 arrow 는 디폴트입니다.
한줄 요약:
React 에서는 rafce 를 사용하고, RN 에서는 rnfe 를 사용하자.
.
.
이제 Hello World 를 출력해보죠. Tailwind 레시피도 조금 추가해보겠습니다.
// /app/index.tsx
import { View, Text } from "react-native";
import React from "react";
// NativeWind 의 유틸리티클래스를 사용하려면 global.css 가 로드 되어야만 합니다.
// /_layout.tsx 를 사용하기 시작하면, 이를 /_layout.tsx 로 옮겨줄 것입니다.
import "../global.css";
const index = () => {
return (
<View className="bg-slate-200 py-4 px-8 ">
<Text className="font-bold">
Hello World
<Text className="text-red-500"> React Native</Text>
</Text>
</View>
);
};
export default index;
이렇게 Hello World 를 완성했습니다.
.
Layout
RRD 와 NextJS 에서 처럼, Expo 에서도 Nested Layout 을 사용할 수 있습니다.
Nested Layout 이 뭔지 잘 기억이 안나는 분들을 위해 잠깐 기억을 살려보면..
웹앱에서 상단 네비게이션바와 푸터가 페이지이동간 고정된 상태로 유지되는 모습을 볼 수 있죠? React 앱에서는, React 의 라우터가 페이지 이동을 가로채고, 고정된
NavBar
Children/Outlet
Footer
구조에서, 가운데 Children/Outlet 의 내용만 바뀐 페이지의 내용으로 렌더링해줌으로써 페이지 이동을 처리하는 구조를 말합니다. 정확히는 이러한 렌더링 레이아웃을 말합니다.
그리고, 앞에서 잠깐 말씀드린대로,
Expo Router 가 앱을 렌더링할 때, 모듈을 찾고 렌더링에 적용시켜나가는 순서는,
/_layout.tsx
/index.tsx
순서로 적용됩니다.
이러한 작동방식을 직접 경험하기 위해, /_layout.tsx
파일을 생성해보죠.
// /_layout.tsx
import { View, Text } from "react-native";
import React from "react";
const RootLayout = () => {
return (
<View className="bg-slate-400 text-neutral-800 py-2 px-4">
<Text className="text-neutral-800">Layout Header</Text>
</View>
);
};
export default RootLayout;
앱 화면에서 /index.tsx
가 완전히 사라지고, /_layout.tsx
파일만 렌더링 되는 걸 확인할 수 있습니다.
.
Slot
이제 /_layout.tsx
파일에 <Slot />
를 추가해볼까요.
import { View, Text } from "react-native";
import React from "react";
import { Slot } from "expo-router";
import "../global.css"; // 이제 global.css 를 이리로 옮겨옵시다.
const RootLayout = () => {
return (
<>
<View className="bg-slate-400 text-neutral-800 py-2 px-4">
<Text className="text-neutral-800">_layout Header</Text>
</View>
<Slot />
</>
);
};
export default RootLayout;
/_layout.tsx
파일이 렌더링 되면서, <Slot />
의 위치에, /index.tsx
파일이 렌더링되는 것을 확인할 수 있습니다. <Slot />
이, Children, Outlet 등과 같다고 말했던 것이 이제 이해가 되는군요.
현재까지의 파일 구조는,
📦app
┣ 📜index.tsx
┗ 📜_layout.tsx
이렇습니다.
여기까지 두줄 요약 :
_layout.tsx 가 먼저 렌더링 되면서,
<Slot /> 의 위치에 index.tsx 가 렌더링 됩니다. index.tsx 를 찾지 못하면 Slot 은 아무것도 렌더링 하지 않습니다.
.
.
SiteMap
Expo Router 의 내장 기능인 SiteMap 은, 현재 개발중인 앱이 포함하고 있는 모든 페이지들의 맵을 보여줍니다.
사용방법도 간단해서,
http://localhost:8081/_sitemap
이렇게 입력하면 출력됩니다. 간단하죠?
개발중에 링크와 페이지가 제대로 작동하지 않을 때, 유용하게 사용할 수 있습니다.
.
.
Link & Pressable
RN 에서의 링크는 웹링크가 아닙니다.
RN 의 링크는, 앱 내부에서 다른 컴포넌트로의 링크를 말한다고 생각하시면 되겠습니다.
링크의 생성과 처리 방식에는 두가지가 있습니다.
Link href=’…’
<Pressable onPress={ () ⇒ do something… }>…</Pressable>
1번 방식은, 모두가 익숙한 하드링크 방식이고,
2번 방식은, onPress 이벤트에 펑션을 바인딩해주는 방식입니다.
Press 이벤트에 펑션을 바인딩하거나 이벤트를 이어가야 할 필요가 있을때에는 2번 방식을 사용하고, 그 외의 상황에서는 1번 방식을 사용하면 되겠습니다.
레이아웃 컴포넌트에, Link 와 Pressable 을 사용한 고정 링크 앱바를 추가해볼까요.
// /app/_layout.tsx
import { View, Text, Pressable } from "react-native";
import React from "react";
import { Link, router, Slot } from "expo-router";
import "../global.css";
const _layout = () => {
return (
<>
<View className="flex flex-row justify-between items-center bg-slate-400 text-neutral-800 py-2 px-4">
<View>
<Pressable onPress={() => router.push("/")}>
<Text className="font-bold text-l text-red-600">
My First RN Work
</Text>
</Pressable>
</View>
<View className="flex flex-row gap-6 px-8">
<Link href="./about">About</Link>
<Link href="./contact">Contact Us</Link>
</View>
</View>
<Slot />
</>
);
};
export default _layout;
앱의 헤더에 간단한 메뉴바를 추가해줬습니다.
한 쪽에서는 Pressable 을 사용했고, 다른 쪽에서는 Link 를 사용해서 네비게이션을 처리하고 있습니다.
그리고, Nested Layout 이 사용되어서, 페이지 이동간에 상단 메뉴바는 고정상태로 유지되고 있습니다.
.
Link
<Link />
의 속성은 세가지로 기억하고 있으면 충분할 것 같습니다.
Link 의 기본 동작은 push 로, 현재 스택에 새로운 경로를 추가합니다.
<Link href="/profile">Go to Profile</Link>
replace 는 현재 페이지를 새 페이지로 대체합니다. 뒤로가기가 생성되지 않습니다.
<Link href="/profile" replace>Go to Profile</Link>
asChild 는, Link 를 자체적으로 렌더링하지 않고, 자식요소에서 렌더링되도록 설정합니다.
<Link href="/profile" asChild>
<Button title="Go to Profile" />
</Link>
직관적으로 이해가 되시죠? 링크 속성이 자식 요소에게 고스란히 전가되지만, 자식요소는 자기 스타일을 유지할 수 있습니다. 이거 다 너 잘되라고 떠넘기는거야
.
*** Link href 프롭의 절대경로에서 Type 에러가 발생하고 있는데, 정확한 원인을 아직 찾지 못하였습니다. 아마도 Expo-Router 의 타입 원형에 문제가 있지 않은가 생각이 들지만, 정확하진 않습니다. StackOverflow 에 같은 문제에 대한 질문이 있었고, 절대경로 대신 상대경로를 지정해서 해결했다는 피드백이 있었습니다. 저도 상대경로로 바꿔주니 Type 에러는 사라졌습니다. Expo-Router 의 타입 원형 문제가 맞는 것 같습니다. 당분간 이 문제가 픽스될 때까지는, TS 에서 Link href 속성은 상대경로로 사용해야 할 것 같습니다.
** 2025.04.12
이 문제를 제가 정확히 이해하지 못하고 정리해서 혼란을 드리게 되었습니다. 죄송합니다.
Link 의 href 에 할당되는 밸류는 Type Guard 가 적용되기 때문에, Expo 의 file based routing 과 Stack 에서 지정된 '정확한' 라추팅 경로가 아니면 타입 에러를 발생시킵니다. Link href 에서 상대경로를 사용하는 것은 Expo 의 타입 가드 규칙과 안배를 벗어나는 행동이므로 사용해선 안되는 코드입니다.
.
Pressable 을 내장하고 있는 컴포넌트
Button 등의 상호작용을 기대하고 있는 컴포넌트들은 기본적으로 Pressable 을 내장하고 있습니다. 때문에 이들 컴포넌트는 Pressable 없이 독자적으로 onPress 펑션을 사용자 반응에 바인딩할 수 있습니다.
하지만 주의할 점은, 이들 컴포넌트들은 오직 onPress 이벤트만 지원하고, onLongPress 를 포함한 다른 터치 이벤트들을 지원하지 않습니다.
Button
CheckBox
Switch
FlatList & SectionList
Touchable* : TouchableOpacity, TouchableHighlight, TouchableWithoutFeedback, TouchableNativeFeedback
// 1. 기본 시스템 버튼
// Button 에 직접 onPress 를 바인딩해도 된다.
<Button
title="Submit"
onPress={handleSubmit}
/>
// 2. 커스텀 디자인 + 다양한 상호작용이 필요할 때
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
className={({pressed}) =>
`p-4 rounded-lg ${
pressed ? 'bg-blue-700' : 'bg-blue-500'
}`
}
>
<Text className="text-white">Custom Button</Text>
</Pressable>
// 3. 간단한 커스텀 + 터치 효과가 필요할 때
<TouchableOpacity
onPress={handlePress}
className="bg-blue-500 p-4"
>
<Text className="text-white">Click with fade</Text>
</TouchableOpacity>
한줄 요약:
누가봐도 사용자 반응을 위해 태어났다고 생각되는 컴포넌트들은 Pressable 없이도 onPress 를, 그러나 오직 onPress 만을, 사용할 수 있다.
그 외의 Touchable* 등은, 일단 그런게 있다는 것 정도만 기억하자.
.
.
Route Groups
괄호로 감싼 디렉토리 (tabs)
는, 해당 디렉토리가 논리적 그룹으로 묶여서 동일한 레이아웃의 영향을 받으면서, 파일 시스템 라우팅 경로에는 영향을 주지 않음을 의미합니다.
NextJS 의 App Router 에서도 같은 방식으로 Route Group 이 사용되고 있죠. NextJS 의 App Router 에 익숙한 분들은 어려움 없이 이해할 수 있을 것 같습니다.
Stack 그룹이 필요할 때, 라우팅 논리그룹으로 묶어주지 않으면, URL 관리가 꼬일 수 있습니다. 때문에, Stack 은 라우팅 논리그룹, Route Group 으로 묶어준다고 외워둡시다.
Route Group
/app/(tabs)/_layout.js
가 존재할 때, 실제 URL에는 **(tabs)
**가 표시되지 않습니다.앱에서 Stack 등이 사용될 때,
Route Group
없이 섞여 있으면, 어떤 화면들이 동일한 흐름(같은 Stack에 속하는지)인지를 직관적으로 이해하기 어렵습니다. 이럴 때,Route Group
을 사용해야 합니다.
.
여기서 주의할 점 한가지…
앞에서 우리는 Slot 을 사용할 때의 파일 시스템 구조가 아래와 같다고 배웠습니다.
📦app
┣ 📜index.tsx
┗ 📜_layout.tsx
위 파일 구조에서는, Slot 으로 index.tsx 로의 라우팅 지시가 존재했기 때문에 index.tsx
가 레이아웃과 함께 렌더링 되었습니다. <Slot />
코드를 삽입해주지 않으면 /index.tsx
가 화면에 렌더링 되지 않았죠.
.
그럼 다음과 같은 파일 구조가 있다고가정해보죠.
참고로, 언더바 로 시작하는 디렉토리는, 앱의 라우팅에 영향을 미치지 않는 디렉토리입니다. 주로 로컬 컴포넌트, 라이브러리 등에 사용됩니다.
📦app
┣ 📂(tabs)
┃ ┣ 📂(songs)
┃ ┃ ┣ 📜index.tsx
┃ ┃ ┗ 📜_layout.tsx
┃ ┗ 📜_layout.tsx
┣ 📂_component
┃ ┗ 📜RootLayout.tsx
┗ 📜_layout.tsx
이 앱에서는 Slot 이 사용되지 않습니다. 그리고, /app/(tabs)/(songs)/index.tsx
파일이 앱의 시작 페이지가 됩니다.
직관적으로는 쉽게 이해가 되지 않는데요..
우리는 이번 Route Group 항목을 시작하면서, Route Group 의 특성으로, 파일 시스템 라우팅 경로에는 영향을 주지 않음
이라고 정의했었죠.
이 때문에 /app/(tabs)/(songs)/index.tsx
파일 경로는, 결과적으로 /app/index.tsx
처럼 핸들됩니다. 그리고, Stack
및 Tabs
는 자동으로 경로와 컴포넌트를 매핑해서 렌더링 해주는 컨테이너 컴포넌트입니다.
즉, 결과적으로는 위에서 Slot 으로 구현한 렌더링과 다르지 않은 결과를 렌더링하게 되는 것입니다.
.
.
Stack
Stack
Stack
은 페이지 이동을 위한 라우팅 방식 의 하나로, 화면 전환 시 스택(쌓임) 을 이용합니다.
사용자가 새 화면으로 이동하면, 이전 화면 위에 새 화면이 차곡차곡 쌓이는 구조입니다. 뒤로 가기(Back) 버튼을 누르면 쌓아올려진 화면의 순서에 따라 이전 화면으로 돌아갑니다.
그리고 Stack
은, 자식 경로를 자동으로 탐색하고 렌더링하므로, <Slot />
없이도 자식경로 이내의 출력 경로가 자동으로 생성됩니다.
/app/_layout.tsx
는 기본적으로 Slot
이 없으면 자식 컴포넌트를 렌더링하지 않는 단순한 레이아웃 파일입니다.
반면에, Stack 은 컨테이너 자체가 자식 컴포넌트를 자동으로 렌더링하도록 설계되어서, 별도의 <Slot />
없이도 자식 경로를 탐색하고 렌더링할 수 있습니다.
.
Stack 과 Route Group 이 함께 사용될 때
다음과 같은 폴더 구조의 앱이 있다고 가정해보죠.
📦app
┣ 📂(tabs)
┃ ┣ 📂(songs)
┃ ┃ ┣ 📜index.tsx
┃ ┃ ┗ 📜_layout.tsx
┃ ┗ 📜_layout.tsx
┣ 📂_component
┃ ┗ 📜RootLayout.tsx
┗ 📜_layout.tsx
/app/_layout.tsx
에서, Stack 코드를 선언해줍니다.
// /app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack
screenOptions={{
headerShown: false, // 앱의 루트 레벨에서는 헤더를 숨김
}}
/>
);
}
// 이제, /app/ 이하 자식 경로에서 라우팅 경로가 자동으로 생성되고 출력됩니다.
그런데, 앞선 장에서, <Stack />
과 <Stack.Screen />
의 중첩 구조를 배웠는데, <Stack.Screen />
은 한번도 쓰이지 않았군요. <Stack.Screen />
은 언제 사용되는 것일까요.
.
Stack.Screen
Expo-Router 는, 파일 기반 라우팅을 지원하기 때문에, Stack 이 선언된 현재 디렉토리 이하의 파일 구조를 로드하고, 이들을 Stack 페이지로 자동으로 관리해줍니다.
이 때문에 Stack.Screen 으로 모든 페이지들을 등록해줄 필요는 없고, 각 페이지에 따른 추가 옵션을 지정해줘야 할 필요가 있을 때, Stack.Screen 을 개별적으로 선언해주면 됩니다.
// /app/_layout.tsx
import { Stack } from 'expo-router';
export default function TabsLayout() {
return (
<Stack
screenOptions={{
headerShown: true, // 탭 내 화면에서는 헤더를 표시
headerStyle: {
backgroundColor: '#6200ee', // 헤더 스타일 설정
},
headerTintColor: '#ffffff', // 헤더 텍스트 및 아이콘 색상
animation: 'slide_from_right', // 화면 전환 애니메이션
}}
>
{/* /songs/ 폴더에만 추가 옵션을 지정 */}
<Stack.Screen
name="(tabs)/(songs)"
options={{
title: 'My Songs',
animation: 'fade',
}}
/>
</Stack>
);
}
.
Stack 의 중첩 구조는, 앱의 복잡성을 심화시킬 수 있기 때문에, 특별한 경우가 아니라면 Stack 의 Depth 를 최소화 하는 것이 좋습니다.
Stack 은, 하위 디렉토리의 모든 경로들을 Stack 으로 알아서 로드해 주기 때문에, 특별한 경우가 아니라면, 하나의 Stack 내에서 Stack.Screen 설정 만으로도 원하는 스택관리를 해줄 수 있습니다.
// /app/_layout.tsx
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
export default function TabsLayout() {
const router = useRouter();
const params = useLocalSearchParams();
return (
<Stack
screenOptions={{
headerShown: false
}}
>
{/* tabs 경로의 논리그룹에 적용되는 규칙을 설정 */}
<Stack.Screen
name="(tabs)"
options={{
headerShown: true, // 탭 내 화면에서는 헤더를 표시
headerStyle: {
backgroundColor: '#6200ee', // 헤더 스타일 설정
},
headerTintColor: '#ffffff', // 헤더 텍스트 및 아이콘 색상
animation: 'slide_from_right', // 화면 전환 애니메이션
}}
/>
{/* /(tabs)/(songs)/ 경로의 논리그룹에 적용되는 규칙을 설정 */}
<Stack.Screen
name="(tabs)/(songs)"
options={{
title: `My Songs : ${params.name}`, // dynamic Title
animation: 'fade',
}}
/>
</Stack>
);
}
Stack 의 하위 폴더 로케이션 중에서 특별히 관리해야 할 라우트그룹에만 Stack.Screen 을 개별 지정해서 옵션을 추가해 줬습니다.
여기서, name
속성에 관해 의문이 드실만 한데요..
Expo Router 는, 폴더 구조에 기반하여 파일 로케이션과 목적 스크린을 매핑합니다
예를 들어, 스크린 네임 (tabs)/(songs)
는 /app/(tabs)/(songs)/
경로로 매핑됩니다.
이 때문에, Expo-Router 프로젝트에서는 /app/(tabs)/(songs)/index.tsx
형식으로 파일 관리를 하는 게 일반적입니다. 같은 경로에서 artists 페이지가 필요하다면, /app/(tabs)/artists.tsx
또는 /app/(tabs)/artists/index.tsx
파일을 생성해 줘야겠죠. 두가지 경우에서 모두, <Stack.Screen name=’(tabs)/artists’ />
로 매핑 될 것입니다.
.
Stack 에서 제거 : dismiss & dismissAll
dismiss 는 요청된 위치로부터 가장 가까운 경로의 Stack 에서 지정 카운트 만큼의 Screen 을 제거합니다. 여기서 Screen 은, Stack.Screen 으로 지정한 코드가 아니라, 사용자가 여러 스크린을 이동해온, 이동기록으로 쌓인 스크린-화면 을 말합니다. history.back() 과 다른 특성이라면, dismiss 는가장 가까운 Stack 을 타겟으로 하고 있고, history.back() 은 내장객체인 Navigator 를 타겟으로 하고 있다는 것입니다.
dismiss 가 파라메터 없이 호출될 때는, 해당 Stack 의 가장 마지막 스크린이 제거되고, 해당 스택에 한개의 스크린만 존재한다면 스택 전체가 제거됩니다. 스택 전체가 제거되면, 사용자는 해당 Stack 이 호출되기 직전의 페이지로 자동으로 이동됩니다.
import { Button, View } from 'react-native';
import { useRouter } from 'expo-router';
export default function Settings() {
const router = useRouter();
const handleDismiss = (count: number) => {
router.dismiss(count)
};
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Button title="Go to first screen" onPress={() => handleDismiss(3)} />
</View>
);
}
.
dismissAll 은 스택의 첫번째 스크린으로 돌아갑니다.
import { Button, View, Text } from 'react-native';
import { useRouter } from 'expo-router';
export default function Settings() {
const router = useRouter();
const handleDismissAll = () => {
router.dismissAll()
};
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Button title="Go to first screen" onPress={handleDismissAll} />
</View>
);
}
.
canDismiss
현재 스크린에서 dismiss 를 사용할 수 있는지 체크하는 펑션입니다. 스택 히스토리에서 자신을 제외한, 한 개 이상의 스택을 찾으면 true 를 반환합니다.
// 스택이 이렇게 쌓여있다고 가정
Stack: [ScreenA, ScreenB, ScreenC(현재)]
canDismiss() => true // ScreenC를 닫아도 ScreenB가 있으므로 true
// 스택이 하나만 있는 경우
Stack: [ScreenA(현재)]
canDismiss() => false // ScreenA를 닫으면 보여줄 화면이 없으므로 false
canDismiss
가true
를 반환 : 현재 화면을 닫아도 돌아갈 이전 화면이 있는 상태canDismiss
가false
를 반환 : 현재 화면이 스택의 유일한 화면인 상태
이 함수는 주로 모달이나 화면을 닫을 수 있는지 판단하는데 사용됩니다:
const router = useRouter();
if (router.canDismiss()) {
router.back(); // 이전 화면으로 돌아가기
}
. .
Stack 없이 Link 만으로 페이지전환을 처리할 때
Stack 이 없어도, Link 를 통한 페이지 이동은 가능하지만, Expo-Router 가 제공하는 Header 등 부가기능과 뒤로가기 이동 & 제스쳐, 그리고 페이지 전환 애니메이션 등이 자동 제공되지 않습니다.
.
Stack 을 사용할 때 주의할 점들…
깊은 네비게이션에서 관리가 복잡해집니다.
여러 트리구조로 Stack 을 쌓다보면 관리가 복잡해질 수 있습니다.
Stack Depth 는 되도록이면 1 이상 들어가지 맙시다.
비정상 종료시 스택이 초기화됩니다.
비정상 종료 후 다시 앱을 가동할 때, 이전 스택 상태를 유지해야 할 필요가 있을 수 있습니다.
이럴 때, RN 의 localStorage 격인 저장소,
AsyncStorage
등을 사용해 스택 상태를 저장하고 복원해줄 수 있습니다.
메모리 사용량 증가
Stack 이 쌓이면 메모리 누수와 성능상 문제가 발생할 수도 있습니다.
dismiss & dismissAll & canDismiss 를 적절히 사용해서 Stack 이 무한히 쌓이는 사태를 방지할 수 있습니다.
.
Stack 실전 코드
이제 우리의 루트 레이아웃과 Stack 을 좀 업그레이드 해봅시다.
📦app
┣ 📂(tabs)
┃ ┣ 📜index.tsx // 텅 비어 있습니다.
┃ ┗ 📜_layout.tsx
┣ 📂_constants // 몇가지 앱 상수들
┃ ┣ 📜assets.tsx
┃ ┗ 📜tokens.tsx
┣ 📂_component
┃ ┗ 📜RootLayout.tsx // 아직 작업 전입니다.
┗ 📜_layout.tsx
..
// /app/_layout.tsx
import { Image, Text, View } from "react-native";
import React, { useEffect, useState } from "react";
import { Stack } from "expo-router";
import "../global.css";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StatusBar } from "expo-status-bar";
import { Ionicons } from "@expo/vector-icons";
import { avatarObj } from "./_constants/assets";
import { appName, iconSize } from "./_constants/tokens";
const defaultAvatar = require("../assets/images/default-avatar.png");
const Layout = () => {
const [avatar, setAvatar] = useState({ uri: "" });
const [noticeCnt, setNoticeCnt] = useState(0);
// Async Data Fetching 을 나중에 추가...
useEffect(() => {
setAvatar({ uri: avatarObj.uri });
setNoticeCnt(1);
}, []);
return (
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<RootNavigation avatar={avatar} noticeCnt={noticeCnt} />
<StatusBar style="auto" />
</GestureHandlerRootView>
</SafeAreaProvider>
);
};
// JS 개발자는 타입선언과 펑션 파라메터의 타입지정을 스킵하시면 됩니다.
type RootNavigationProps = {
avatar: { uri: string };
noticeCnt: number;
};
const RootNavigation = ({ avatar, noticeCnt }: RootNavigationProps) => {
return (
<>
<Stack>
<Stack.Screen
name="(tabs)"
options={{
headerLeft: () => (
<Image
source={
avatar.uri ? { uri: avatar.uri } : defaultAvatar // 기본 이미지
}
className="rounded-full mx-4 "
style={{
width: iconSize.base,
height: iconSize.base,
resizeMode: "cover",
}}
/>
),
headerRight: () => (
<View className="relative flex flex-row gap-0">
<Ionicons
name="notifications-outline"
size={iconSize.base}
color="black"
className="mx-2"
/>
<Text className="absolute -x-1 w-5 h-5 px-1.5 rounded-full bg-red-400 text-gray-100">
{noticeCnt}
</Text>
<Ionicons
name="settings-outline"
size={iconSize.base}
color="black"
className="mx-2"
/>
</View>
),
headerTitle: () => (
<View className="flex flex-row justify-center items-center">
<Ionicons
name="logo-stackoverflow"
size={iconSize.base}
color="black"
className="mx-2"
/>
<Text className=" font-bold">{appName}</Text>
</View>
),
headerTitleAlign: "center", // 타이틀 중앙 정렬
}}
/>
</Stack>
{/* <Slot /> */}
</>
);
};
export default Layout;
위 코드가 실행된 앱의 화면입니다.

.
앱의 외형이 대충 갖춰져 가는 것 같죠? 힘들여 NativeWind 를 설정한 보람이 느껴지는군요.
Stack 을 구성하는 RootNavigation 을 분리해줬습니다.
GestureHandlerRootView
는 더블 탭, 스와이프 등의 손가락 제스쳐 에 대응하는 다양한 솔루션을 추가할 수 있게 하는 컴포넌트입니다.
GestureHandlerRootView
로 감싼 이후에 TapGestureHandler
등으로 액션에 반응하는 코드를 추가해주면 되는데, 지금 다뤄야 할 주제는 아니니 나중으로 미루겠습니다.
여기서는 <Stack.Screen />
을 자세히 살펴보죠.
(tabs)
에 대해서 Screen 규칙이 선언되었습니다.Option 으로 네가지 속성이 추가되었죠. 이 네가지 속성들은
(tabs)
내에서 전역적으로 적용됩니다.headerLeft 에 이미지가 지정되었습니다. avatar.uri 는 state 로, 상태가 변경되면 headerLeft 의 이미지가 다시 렌더링 될 것입니다.
handlerRight 에도 state 로 noticeCnt 가 추가되었습니다.
Image 등 일부 RN-Expo 컴포넌트에서는 Tailwind 클래스가 적용되지 않고 튀는 경우가 종종 있습니다. 이럴땐 그냥 style 을 추가하고 넘어가는게 좋습니다.
옛다 styleDom 이 어떻게 그려지는 지 살펴보시면 알겠지만, Expo 의 Dom 구조는 상당히 복잡하게 렌더링 됩니다. Dom 이 복잡하게 얽히다보니 유틸리티 클래스나 스타일도 아예 안 먹히는 경우가 많은데, 이럴 땐 text-align 같은 속성이 별도의 옵션으로 제공됩니다. 이런 이유로, 옵션이 너무 많습니다. 필요할 때 공홈을 참고해서 필요한 옵션을 찾아서 추가해주겠다… 이정도로 기억하고 넘어가죠.
.
React 의 useState 를 추가해서 /app/_layout.tsx
를 업그레이드 해보니, 이제 감이 좀 오는 것 같죠? 내안의 React 본능이 막 꿈틀대기 시작합니다.ㅋㅋㅋ
.
.
.
Tabs
처음에 우리는 <Link />
와 <Pressable />
을 이용해서 탭바에 해당하는 메뉴들을 만들어봤죠.
Expo Router 는 탭바를 자동으로 생성해주는 <Tabs />
라는 컴포넌트를 제공합니다.
이제 다시, 아래와 같은 폴더 스트럭쳐가 있다고 가정해보죠.
📦app
┣ 📂(tabs)
┃ ┣ 📂(songs)
┃ ┃ ┗ 📜index.tsx
┃ ┗ 📜_layout.tsx
┣ 📂_component
┃ ┗ 📜RootLayout.tsx
┣ 📂_constants // 몇가지 앱 상수들
┃ ┣ 📜assets.tsx
┃ ┗ 📜tokens.tsx
┗ 📜_layout.tsx
여기까지는 앞서 Stack 으로 구성한 폴더구조와 크게 바뀐게 없죠. /app/(tabs)/(songs)/
라우트그룹 폴더가 추가되었습니다.
.
(tabs) 폴더
(tabs)
폴더명은, 해당 디렉토리의 하위 구조를 인식하고, Tabs
코드와 함께 탭-바 구조를 자동으로 구성해줍니다. 예약된 폴더네임이므로, 이름을 준수해야 합니다.
탭바 옵션의 변경이나 지정은, /app/(tabs)/_layout.tsx
에서 해줍니다.
조금 헤깔리는 느낌이 있긴 하지만, 곰곰히 생각해보면 그 구조를 납득할 수 있습니다. /app/_layout.tsx
에서는 Stack
을, 그리고, /app/(tabs)/_layout.tsx
에서는 Tabs
를 지정해줍니다. 이해하기 어려울 땐 그냥 외웁시다.
이제 앱에 탭바를 추가해보죠.
(tabs)/_layout.tsx
를 열고 아래 코드를 추가합니다.
// /app/(tabs)/_layout.tsx
import { Ionicons } from "@expo/vector-icons";
import FontAwesome from "@expo/vector-icons/FontAwesome";
import { BlurView } from "expo-blur";
import { Slot, Tabs } from "expo-router";
import { iconSize } from "../_constants/tokens";
import { Platform, StyleSheet } from "react-native";
export default function TabLayout() {
return (
<>
<Tabs screenOptions={{ tabBarActiveTintColor: "blue" }}>
<Tabs.Screen
name="(songs)/index"
options={{
title: "Home",
headerTitle: "Home Screen",
headerShown: true,
headerStyle: {
backgroundColor: "gainsboro",
},
headerBackground: () => (
<BlurView intensity={100} style={{ flex: 1 }} />
),
tabBarIcon: ({ color }) => (
<Ionicons size={iconSize.sm} name="home" color={color} />
),
// IOS only
...(Platform.OS === "ios"
? {
tabBarStyle: { position: "absolute", elevation: 0 },
tabBarBackground: () => (
<BlurView
tint="prominent"
intensity={100}
style={StyleSheet.absoluteFill}
/>
),
}
: undefined),
}}
/>
</Tabs>
{/* <Slot /> */}
</>
);
}
<Tabs />
의 name
프롭에도 <Stack />
에서 적용되었던 네임-매핑 규칙이 그대로 적용됩니다. /app/(tabs)/(songs)/index.tsx
에 대해 (songs)/index
라는 이름이 매핑되었습니다. 현재 경로에 대한 상대경로이므로 (tabs)
가 빠졌다고 이해하면 별다른 어려움은 없겠습니다.
옵션들은 천천히 알아보고, 먼저 앱을 띄워보고 그 결과를 확인해 봅시다.

.
탭바의 Home 아이콘이 잘 나오고 있습니다. 현재 선택할 수 있는 탭 메뉴는 Home 하나 뿐이지만, 탭바는 잘 작동하고 있습니다.
그런데, 화면 상단에 타이틀 영역이 생겼군요. 옵션에서 지정해줬기 때문에 출력되고 있습니다.
headerTitle, headerShown, headerStyle, 등은 직관적으로 무엇을 의미하는지 알 수 있겠죠.
headerBackground 에서 사용한 BlurView 컴포넌트는 IOS 의 블러뷰를 만들어주는 컴포넌트입니다. IOS 코드에서도 탭바에 다시한번 사용되었죠.
tabBarIcon 은 하단의 탭바에서 해당 페이지에 표시될 아이콘을 지정하고 있습니다.
.
이제 탭바에 다른 메뉴를 추가해보죠.
/app/(tabs)/artists/index.tsx
와 /app/(tabs)/playlists/index.tsx
를 만들어줍니다.
생성후 현재까지 프로젝트 디렉토리 구조는 아래와 같습니다.
📦app
┣ 📂(tabs)
┃ ┣ 📂(songs)
┃ ┃ ┗ 📜index.tsx
┃ ┣ 📂artists
┃ ┃ ┗ 📜index.tsx
┃ ┣ 📂playlists
┃ ┃ ┗ 📜index.tsx
┃ ┗ 📜_layout.tsx
┣ 📂_component
┃ ┗ 📜RootLayout.tsx
┣ 📂_constants
┃ ┣ 📜assets.tsx
┃ ┗ 📜tokens.tsx
┗ 📜_layout.tsx
그리고, Tabs 에 다음 코드를 추가해줍니다.
// /app/(tabs)/_layout.tsx
// <Tabs> 내부에 <Tabs.Screen> 코드를 두개 더 추가해줍니다.
...
<Tabs.Screen
name="artists/index"
options={{
title: "Artists",
tabBarIcon: ({ color }) => (
<Ionicons size={28} name="musical-notes" color={color} />
),
}}
/>
<Tabs.Screen
name="playlists/index"
options={{
title: "PlayLists",
tabBarIcon: ({ color }) => (
<Ionicons size={28} name="document-text" color={color} />
),
}}
/>
앱 화면에 추가된 탭바 메뉴가 보이죠? 탭바의 아이콘을 클릭 터치 해봅시다. 페이지 전환이 잘 되고 있군요.
탭바가 제대로 표현되고 있지 않으면, <Tab.Screen>
의 name
속성이 맞게 지정되었는지 확인해보세요. 상대경로가 정확하게 지정되어야 합니다.
.
.
.
Dynamic Route
RN 에서 다이내믹 라우팅은..
아직 데이터와 파람을 사용할 단계는 아니니까, 오늘은 어떤 구문으로 다이내믹 라우팅이 사용되는지 구경만 하고 넘어가기로 하죠.
📦app
┣ 📂(tabs)
┃ ┣ 📂(songs)
┃ ┃ ┣ 📂details
┃ ┃ ┃ ┗ 📜[id].tsx
┃ ┃ ┗ 📜index.tsx
┃ ┣ 📂artists
┃ ┃ ┗ 📜index.tsx
┃ ┣ 📂playlists
┃ ┃ ┣ 📂list
┃ ┃ ┃ ┗ 📜[id].tsx
┃ ┃ ┗ 📜index.tsx
┃ ┗ 📜_layout.tsx
┗ 📜_layout.tsx
/app/(tabs)/(songs)/details/[id].tsx
경로가 만들어졌습니다.
NextJS, RRD 에 익숙한 분들은 바로 느낌이 오죠?
NextJS 에서 /[id]/page.tsx
로 사용되던 다이내믹 경로가 /[id].tsx
로 살짝 바뀌었습니다.
다이내믹 URL 은, /app/details/3
, /app/playlists/3
의 형식이 될 것입니다.
그럼, 파라메터를 받아서 처리하는 코드를 보죠.
// /app/(tabs)/(songs)/details/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
export default function DetailsScreen() {
const { id } = useLocalSearchParams();
return (
<View className="flex">
<Text>Details of song {id} </Text>
</View>
);
}
코드가 친숙하게 느껴집니다. RRD, NextJS 와 다른 점은, useLocalSearchParams
펑션 이름 하나 뿐인 듯 하군요.
다이내믹 파람, parameter name 을 /[id].tsx
이렇게 id
로 지정해줬기 때문에, const { id } = useLocalSearchParams();
이렇게 id 를 돌려받았습니다.
만약, slug 를 받아야 한다면,
/[slug].tsx
const { slug } = useLocalSearchParams();
이렇게 사용하면 되겠죠.
다이내믹 라우트는 앞으로 실제로 Data 를 핸들링하면서 더 다루게 될 것입니다.
.
.
.
.
Dark Mode
React 와 NextJS 프로젝트에서 Tailwind 로 구현했던 Dark Mode 는, Dom 의 Root Class 를 참조하면서 CSS Variables 를 스위치 해줌으로써 쉽고 간단하게 구현할 수 있었습니다.
하지만 RN 에서는, 앱 전역에서 참조할수 있는 Root Class 같은게 존재하질 않아서, 다크 모드 구현이 여간 번거로운 일이 아닙니다.
몇가지 찾아본 결과로는, 모든 디스플레이 컴포넌트마다 Dark 접두어 클래스를 별도로 추가해주는 방법과 그 비슷한 것들이 있었지만, 제가 원하는 방식은 아니었습니다.
제가 생각하는 방법은….
먼저, global.css 의 컬러셋을 JS Object 로 변환합니다.
현재 선택된 colorScheme : string 값에 따라 컬러셋 중 일부를 로드합니다.
간단한 듯 답답한데, 자세히 풀어 보겠습니다.
다른 좋은 방법을 알고계신 분들께서는 공유해 주시기를 부탁드립니다.
1. 컬러셋 오브젝트 & 펑션
NextJS 에서 닳도록 사용하던 global.css 의 컬러 선언들을 AI 에게 JS 오브젝트로 만들어 달라고 시켰습니다.
// /app/_constants/colors.ts
export const colors = {
light: {
background: "hsl(0 0% 100%)",
foreground: "hsl(20 14.3% 4.1%)",
card: "hsl(0 0% 100%)",
cardForeground: "hsl(20 14.3% 4.1%)",
popover: "hsl(0 0% 100%)",
popoverForeground: "hsl(20 14.3% 4.1%)",
primary: "hsl(24 9.8% 10%)",
primaryForeground: "hsl(60 9.1% 97.8%)",
secondary: "hsl(60 4.8% 95.9%)",
secondaryForeground: "hsl(24 9.8% 10%)",
muted: "hsl(60 4.8% 95.9%)",
mutedForeground: "hsl(25 5.3% 44.7%)",
accent: "hsl(60 4.8% 95.9%)",
accentForeground: "hsl(24 9.8% 10%)",
destructive: "hsl(0 84.2% 60.2%)",
destructiveForeground: "hsl(60 9.1% 97.8%)",
border: "hsl(20 5.9% 90%)",
input: "hsl(20 5.9% 90%)",
ring: "hsl(20 14.3% 4.1%)",
chart: {
c1: "hsl(12 76% 61%)",
c2: "hsl(173 58% 39%)",
c3: "hsl(197 37% 24%)",
c4: "hsl(43 74% 66%)",
c5: "hsl(27 87% 67%)",
},
},
dark: {
background: "hsl(20 14.3% 4.1%)",
foreground: "hsl(60 9.1% 97.8%)",
card: "hsl(20 14.3% 4.1%)",
cardForeground: "hsl(60 9.1% 97.8%)",
popover: "hsl(20 14.3% 4.1%)",
popoverForeground: "hsl(60 9.1% 97.8%)",
primary: "hsl(60 9.1% 97.8%)",
primaryForeground: "hsl(24 9.8% 10%)",
secondary: "hsl(12 6.5% 15.1%)",
secondaryForeground: "hsl(60 9.1% 97.8%)",
muted: "hsl(12 6.5% 15.1%)",
mutedForeground: "hsl(24 5.4% 63.9%)",
accent: "hsl(12 6.5% 15.1%)",
accentForeground: "hsl(60 9.1% 97.8%)",
destructive: "hsl(0 62.8% 30.6%)",
destructiveForeground: "hsl(60 9.1% 97.8%)",
border: "hsl(12 6.5% 15.1%)",
input: "hsl(12 6.5% 15.1%)",
ring: "hsl(24 5.7% 82.9%)",
chart: {
c1: "hsl(220 70% 50%)",
c2: "hsl(160 60% 45%)",
c3: "hsl(30 80% 55%)",
c4: "hsl(280 65% 60%)",
c5: "hsl(340 75% 55%)",
},
},
} as const;
// 사용 예시
export const getColors = (colorScheme: "light" | "dark" | undefined) => {
if (colorScheme === undefined) {
return colors.light;
}
return colors[colorScheme];
};
2. colorScheme 상태값으로 getColors() 를 호출
// useColorScheme() 은 nativewind 에서 제공하는 컨텍스트 펑션입니다.
// 이름만 컬러일 뿐, string context 를 제공하는 게 전부입니다.
// 전역 컨텍스트 colorScheme : 'light' | 'dark' | undefined
const { colorScheme, toggleColorScheme } = useColorScheme();
// getColors() 로, light | dark 에 해당하는 컬러셋 오브젝트를 받음.
const currentColors = getColors(colorScheme as "light" | "dark" | undefined);
3. currentColors 사용
// currentColors 를 그냥 사용하면 된다.
<Stack.Screen
name="(tabs)"
options={{
headerShown: true,
headerStyle: {
backgroundColor: currentColors.background,
},
headerTintColor: currentColors.foreground,
...
/>
.
원하는 결과물이 출력된 것 까지는 만족스럽습니다.
하지만, 사실 여기에서는 어차피 Tailwind 클래스를 쓸 수 없고, styles 객체를 사용할 수 밖에 없다는 사연과 제약이 있습니다. Stack 과 Tabs 에서는 classNames 속성이 아직(2024.11) 지원되지 않기 때문입니다.
그리고, 현재 시점에서 우리에게 주어진 숙제는 Dark 모드의 구현인데, 글로벌 state 에 따라 CSS 변수 그룹을 분기할 수 없다는 것이 이 작업의 가장 큰 취약점입니다. 그리고, 주류 컴포넌트들이 CSS 변수를 반영할 수 없다는 것 역시 문제입니다. Stack 에, className=’…’ 으로, 우리가 원하는 tailwind 클래스를 추가해줄 수 있다면, 옵션을 주렁주렁 달아줄 필요도 없겠죠.
저에게는 아직까지 다른 선택의 여지가 없었습니다. global.css 와 getColors() 두가지 변수 자원을 병행해 관리해야 한다는 것은 분명히 잘못된 출발이지만, 이 프로젝트를 출발한 목적 자체가 RN+Expo+Tailwind 였으니까요. 이 조합이 앞으로 얼마나 더 심각한 모순을 빚어낼 지는 모르겠습니다만, 그냥 가보죠. 아직까지는 그래도 꽤 좋은 점들이 더 돋보입니다.
.
.
.
.
.
Project
하다보니 생각보다 많이 길어졌네요…
여기까지의 소스코드가,
https://github.com/KangWoosung/expo_router_exercise
https://stackblitz.com/~/github.com/KangWoosung/expo_router_exercise
여기 올려져 있습니다.
스택블리츠에서 RN-Expo 가 잘 작동하지 않는 것 같습니다. 깃헙에도 같은 경로의 레포지토리가 있으니 다운받으실 분들은 깃헙에서 받으시면 되겠습니다.
앱의 워킹 데모는, codesandbox 의 프리뷰로 볼 수 있습니다.
https://46w43l-8081.csb.app/artists
.
이번 장의 과제 프로젝트는 없습니다. 분량이 너무 길어지기도 했고, 아직은 프로젝트를 해볼만한 밑천이 얼마 없네요.
하지만, 이번 장의 과정들을 꼭 한번, 두번 씩은 직접 따라서 코딩하고 결과물을 확인하는 시간을 가져보시길 바랍니다.
긴 글 따라오시느라 수고하셨습니다.