RN#2. Routing in React Native

avatar
2024.11.27
·
46 min read

  1. Hello World RN

  2. Link & Pressable

  3. Route Groups

  4. Stack

  5. Tabs

  6. Dynamic Route

  7. Dark Mode

  8. Project

.

.

지난 장에서 공개한 Expo-NativeWind 스타터 깃 은 React Native 와 Expo, Tailwind 를 사용할 수 있도록 사전 세팅된 작업환경을 간단하게 생성할 수 있어서 편리합니다.

하지만, 템플릿의 기본 앱 화면은 우리가 만들고 싶은 앱과는 다른 구조일 수 있죠.

앱의 템플릿을 제거하고, 백지상태에서 Hello World 부터 시작해 봅시다.

먼저, /app 디렉토리의 모든 파일과 폴더들을 제거합니다.

그리고, /app/index.tsx 파일을 생성합니다.

참고로, Expo Router 가 앱을 렌더링하기 위해 파일 모듈을 찾고 렌더링에 반영해가는 순서는 아래와 같습니다.

  1. /app/_layout.tsx - Nested Layout 적용

  2. /app/index.tsx - App 페이지

  3. 자식 컴포넌트

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 가 앱을 렌더링할 때, 모듈을 찾고 렌더링에 적용시켜나가는 순서는,

  1. /_layout.tsx

  2. /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

이렇습니다.

여기까지 두줄 요약 :

  1. _layout.tsx 가 먼저 렌더링 되면서,

  2. <Slot /> 의 위치에 index.tsx 가 렌더링 됩니다. index.tsx 를 찾지 못하면 Slot 은 아무것도 렌더링 하지 않습니다.

.

.

SiteMap

Expo Router 의 내장 기능인 SiteMap 은, 현재 개발중인 앱이 포함하고 있는 모든 페이지들의 맵을 보여줍니다.

사용방법도 간단해서,

http://localhost:8081/_sitemap

이렇게 입력하면 출력됩니다. 간단하죠?

개발중에 링크와 페이지가 제대로 작동하지 않을 때, 유용하게 사용할 수 있습니다.

.

.

Link & Pressable

RN 에서의 링크는 웹링크가 아닙니다.

RN 의 링크는, 앱 내부에서 다른 컴포넌트로의 링크를 말한다고 생각하시면 되겠습니다.

링크의 생성과 처리 방식에는 두가지가 있습니다.

  1. Link href=’…’

  2. <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 를 포함한 다른 터치 이벤트들을 지원하지 않습니다.

  1. Button

  2. CheckBox

  3. Switch

  4. FlatList & SectionList

  5. 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 처럼 핸들됩니다. 그리고, StackTabs는 자동으로 경로와 컴포넌트를 매핑해서 렌더링 해주는 컨테이너 컴포넌트입니다.

즉, 결과적으로는 위에서 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
  1. canDismisstrue를 반환 : 현재 화면을 닫아도 돌아갈 이전 화면이 있는 상태

  2. canDismissfalse를 반환 : 현재 화면이 스택의 유일한 화면인 상태

이 함수는 주로 모달이나 화면을 닫을 수 있는지 판단하는데 사용됩니다:

const router = useRouter();

if (router.canDismiss()) {
  router.back();  // 이전 화면으로 돌아가기
}

. .

Stack 없이 Link 만으로 페이지전환을 처리할 때

Stack 이 없어도, Link 를 통한 페이지 이동은 가능하지만, Expo-Router 가 제공하는 Header 등 부가기능과 뒤로가기 이동 & 제스쳐, 그리고 페이지 전환 애니메이션 등이 자동 제공되지 않습니다.

.

Stack 을 사용할 때 주의할 점들…

  1. 깊은 네비게이션에서 관리가 복잡해집니다.

    1. 여러 트리구조로 Stack 을 쌓다보면 관리가 복잡해질 수 있습니다.

    2. Stack Depth 는 되도록이면 1 이상 들어가지 맙시다.

  2. 비정상 종료시 스택이 초기화됩니다.

    1. 비정상 종료 후 다시 앱을 가동할 때, 이전 스택 상태를 유지해야 할 필요가 있을 수 있습니다.

    2. 이럴 때, RN 의 localStorage 격인 저장소, AsyncStorage 등을 사용해 스택 상태를 저장하고 복원해줄 수 있습니다.

  3. 메모리 사용량 증가

    1. Stack 이 쌓이면 메모리 누수와 성능상 문제가 발생할 수도 있습니다.

    2. 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;

위 코드가 실행된 앱의 화면입니다.

2342

.

앱의 외형이 대충 갖춰져 가는 것 같죠? 힘들여 NativeWind 를 설정한 보람이 느껴지는군요.

Stack 을 구성하는 RootNavigation 을 분리해줬습니다.

GestureHandlerRootView 는 더블 탭, 스와이프 등의 손가락 제스쳐 에 대응하는 다양한 솔루션을 추가할 수 있게 하는 컴포넌트입니다.

GestureHandlerRootView 로 감싼 이후에 TapGestureHandler 등으로 액션에 반응하는 코드를 추가해주면 되는데, 지금 다뤄야 할 주제는 아니니 나중으로 미루겠습니다.

여기서는 <Stack.Screen /> 을 자세히 살펴보죠.

  1. (tabs) 에 대해서 Screen 규칙이 선언되었습니다.

  2. Option 으로 네가지 속성이 추가되었죠. 이 네가지 속성들은 (tabs) 내에서 전역적으로 적용됩니다.

  3. headerLeft 에 이미지가 지정되었습니다. avatar.uri 는 state 로, 상태가 변경되면 headerLeft 의 이미지가 다시 렌더링 될 것입니다.

  4. handlerRight 에도 state 로 noticeCnt 가 추가되었습니다.

  5. Image 등 일부 RN-Expo 컴포넌트에서는 Tailwind 클래스가 적용되지 않고 튀는 경우가 종종 있습니다. 이럴땐 그냥 style 을 추가하고 넘어가는게 좋습니다. 옛다 style

  6. Dom 이 어떻게 그려지는 지 살펴보시면 알겠지만, 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) 가 빠졌다고 이해하면 별다른 어려움은 없겠습니다.

옵션들은 천천히 알아보고, 먼저 앱을 띄워보고 그 결과를 확인해 봅시다.

2343

.

탭바의 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 접두어 클래스를 별도로 추가해주는 방법과 그 비슷한 것들이 있었지만, 제가 원하는 방식은 아니었습니다.

제가 생각하는 방법은….

  1. 먼저, global.css 의 컬러셋을 JS Object 로 변환합니다.

  2. 현재 선택된 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

.

이번 장의 과제 프로젝트는 없습니다. 분량이 너무 길어지기도 했고, 아직은 프로젝트를 해볼만한 밑천이 얼마 없네요.

하지만, 이번 장의 과정들을 꼭 한번, 두번 씩은 직접 따라서 코딩하고 결과물을 확인하는 시간을 가져보시길 바랍니다.

긴 글 따라오시느라 수고하셨습니다.