ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [RN 강의] 웹 개발과 유사한 Expo 앱 개발
    강의노트/React Native 2025. 9. 6. 11:39

     

    1. 프로젝트 초기화

    React Native 프로젝트에서 불필요한 파일과 폴더 정리한다. 아래 명령어를 실행하면 자동으로 프로젝트를 초기화 하여 App 폴더에 _layout.tsx 와 index.tsx 만 남게 된다.

    npm run reset-project

    reset-project를 실행한 결과

     

    2. App 라우팅

    Expo router는 Next router의 영향을 강하게 받아서 매우 유사하다. Next Router와 같이 page하나가 각각의 주소를 가진다. 주의

    해야할 점은 app 주소의 경우에 프로토콜 자리에 https 대신 app 이름을 붙여 아래와 같은 형태가 된다. 이때 일반적인 페이지는 문제가 없지만, 수백만명이 될 수 있는 사용자 전용 페이지의 경우에는 동적 주소를 사용한다.

     

    app 라우팅 주소 예시

    • threads-clone://home
    • threads-clone://search
    • threads-clone://activity
    • threads-clone://@zerocho
    • threads-clone://@zuck
    • threads-clone://@elonemusk

    3. 동적 라우팅

    위 예시에서 다양한 사용자의 이름은 동적 주소는 대괄호를 사용해서 [username].tsx와 같이 파일명을 작성해주면 된다. 이렇게 되면 home, search, activity를 제외한 나머지는 [username]에 걸리게 되어 대응할 수 있다. 이를 동적 라우터라고 부른다. 이와 유사하게 +not-found.tsx 파일이 있는데, 동적 라우터를 사용하지 않는 경우에 home, search, activity를 제외한 나머지 경로 대해 not-found 페이지로 이동하게 끔 대응할 수 있다. 한 단계 더 들어가는 경로(depth)를 만들어주고 싶다면 app/acitivity/recomment.tsx 와 같이 폴더 구조를 만들어주면 된다.

    동적 라우팅의 폴더 구조 적용

     

    3. 탭 네비게이션

    리액트 네비게이션에는 아래 3 가지 유형이 유명하다. stack 네비게이션의 경우 페이지들이 겹겹이 쌓이는 웹 사이트라고 생각하면 된다. 사용자 히스토리에 이전 페이지와 다음 페이지가 현재 페이지의 앞, 뒤로 쌓여 있어서 빠르게 페이지 전환이 가능한 기능이다. tap 네비게이션은 일반적인 앱에서 많이 사용하고 있는 형태로, 카카오톡의 경우 특정 페이지 (친구, 채팅, 오픈채팅, 쇼핑, 더보기) 로 이동할 수 있는 기능이다. drawer 네비게이션의 경우는 drawer 버튼을 눌렀을 때, 서랍처럼 선택 메뉴들이 열리면서 보여지는 구조의 페이지 이동 기능이다. drawer 네비게이션은 별도의 패키지 설치가 필요하니, stack과 tab 네비게이션을 구현해본다.

     

    대표적인 React Navigation의 종류

    • stack
    • tab
    • drawer

    그룹 폴더

    React Native에는 그룹 폴더라는 특수한 폴더가 존재한다. 소괄호로 묶인 이름을 가진 폴더인데, 소괄호로 묶은 폴더는 주소에 영향을 미치지 않는다. 그리고 폴더를 하나 생성할 때마다 _layout.tsx을 생성하여 추가적인 레이이아웃을 적용할 수 있다.

    (tabs) 폴더 적용

     

    app/(tabs)/_layout.tsx 파일을 아래와 같이 작성한다.

    import { Tabs } from 'expo-router';
    
    export default function TabLayout() {
      return <Tabs />;
    }

     

    app/_layout.tsx 파일을 아래와 같이 Stack에서 Slot으로 수정한다. slack으로 되어 있는 경우에는 (tabs) 폴더명이 앱 상단의 페이지 이름으로 적용된다.

    import { Slot } from 'expo-router';
    
    export default function RootLayout() {
      return <Slot />;
    }

     

    (주의!) Tabs 컴포넌트와 Slot 컴포넌트는 div와 같은 역할을 하는 <View>로 감싸지 말자. UI가 깨진다.

     

    탭 네비게이션 적용

     

    레이아웃

    Layout의 개념은 공통 분모라고 생각하면 된다. app 하위의 _layout.tsx는 전체 앱의 레이이아웃이며, (tabs) 폴더 하위의 _layout.tsx 파일은 탭의 레이이아웃으로 볼 수 있다. 그래서 /app/home.tsx의 경우 전체 레이아웃만 적용되지만, /app(tabs)/activity.tsx 파일의 경우 전체 레이아웃과 tab 레이아웃이 모두 적용된다.

    만약 로그인 후 레이아웃이 로그인 전과 상이 하다면 (afterLogin) 폴더를 만들어 새로운 _layout.tsx 파일을 적용해볼 수 있다. 전체 레이아웃의 경우에는 화면을 넣지 않더라도, Google Analytics라던지 초기화(권한 설정)를 넣는다. afterLogin에 적용된 레아웃에는 로그인 후에 사용자의 정보를 보여주는 공통 레이아웃을 적용해줄 때 활용할 수 있다.

    소괄호를 사용하는 그룹 폴더는 보통 새로운 레이아웃을 적용하기 위해 사용한다. 그렇기 때문에 새로운 레이아웃을 적용하기 위해서 기억해야 할 것은 그룹 폴더와 하위 _layout.tsx 파일이다.

     

    탭 네비게이션 커스터마이징

    기본 값으로 설정되어 있는 탭 네비게이션을 커스터마이징 해보자. 탭 항목으로 나오지 않았던 (tabs) 폴더 하위 파일들에 index와 같이 export default를 적용해주면 각 해당 탭들도 앱 상에서 노출된다. expo에서 기본으로 제공하는 expo/vector-icons를 사용해서 탭을 꾸며줄 수 있다. Ionicons 외에도 더 다양한 vector-icons과 사용법을 지원하고 있다. Tabs 컴포넌트와 Tab.Screen 컴포넌트의 options를 이용해 아래와 같이 커스터마이징을 해볼 수 있다.

     

    import { FontAwesome, Ionicons } from '@expo/vector-icons';
    import { Tabs } from 'expo-router';
    
    export default function TabLayout() {
      return (
        <Tabs
          screenOptions={{
            headerShown: false,
          }}
        >
          <Tabs.Screen
            name="index"
            options={{
              tabBarLabel: () => null,
              tabBarIcon: ({ focused }) => (
                <Ionicons
                  name="home"
                  size={24}
                  color={focused ? 'black' : 'gray'}
                />
              ),
            }}
          />
          <Tabs.Screen
            name="search"
            options={{
              tabBarLabel: () => null,
              tabBarIcon: ({ focused }) => (
                <Ionicons
                  name="search"
                  size={24}
                  color={focused ? 'black' : 'gray'}
                />
              ),
            }}
          />
          <Tabs.Screen
            name="activity"
            options={{
              tabBarLabel: () => null,
              tabBarIcon: ({ focused }) => (
                <Ionicons
                  name="play"
                  size={24}
                  color={focused ? 'black' : 'gray'}
                />
              ),
            }}
          />
          <Tabs.Screen
            name="add"
            options={{
              tabBarLabel: () => null,
              tabBarIcon: ({ focused }) => (
                <FontAwesome
                  name="plus-square"
                  size={24}
                  color={focused ? 'black' : 'gray'}
                />
              ),
            }}
          />
          <Tabs.Screen
            name="[userName]"
            options={{
              tabBarLabel: () => null,
              tabBarIcon: ({ focused }) => (
                <FontAwesome
                  name="user-circle-o"
                  size={24}
                  color={focused ? 'black' : 'gray'}
                />
              ),
            }}
          />
        </Tabs>
      );
    }

     

    옵션에 대해 자세히 찾아보고자 할 때는 다음과 같은 순서로 유추해볼 수 있다.

      1. 궁금한 옵션에 커서를 두고 F12 (go to definition)을 통해 해당 타입 문서로 이동

      2. 타입 문서의 파일 경로를 통해 관련 라이브러리와 검색어를 유추

      3. 유추한 키워드로 검색

     

    tabBarLabel의 경우 아래와 같은 경로를 가지므로, react-navigation에서 bottom-tabs를 참고하면 된다는 것을 알 수 있다. 또는 Cursor IDE를 사용하고 있다면, 간단하게 코드 상에서 드래그하고 Add to Chat (ask)으로 AI에게 질문해서 사용법을 알 수 있다. (ex. 이 옵션이 뭐야?, 이 옵션 말고 다른 옵션으로 뭐가 있어?)

    node_modules > @react-navigation > bottom-tabs > src > type.tsx > BottomNavigationOptions

     

    tab icon 수정 결과

     

    이벤트 등록

    다른 탭 메뉴와 다르게 add 페이지의 경우 모달이 뜨도록 기능을 구성할 것이다. 그래서 이벤트 리스너를 추가해 tab 메뉴를 선택했을 기본 동작인 페이지 이동을 막고 모달이 뜨도록 기능을 추가해주자.

    import { Tabs, useRouter } from 'expo-router';
    
    export default function TabLayout() {
      const router = useRouter();
    
      ...
      
       <Tabs.Screen
          name="add"
          listeners={{
            tabPress: (e) => {
              e.preventDefault();
              router.navigate('/modal');
            },
          }}
          options={{
            tabBarLabel: () => null,
            tabBarIcon: ({ focused }) => (
              <Ionicons name="add" size={24} color={focused ? 'black' : 'gray'} />
            ),
          }}
        />

     

    (taps) 폴더 내부가 아닌 app 하위에 /app/modal.tsx 파일을 생성하여 아래와 같이 작성해주자.

    import { useRouter } from 'expo-router';
    import { Pressable, Text, View } from 'react-native';
    
    export default function Modal() {
      const router = useRouter();
      return (
        <View>
          <Text>I'm a modal</Text>
          <Pressable onPress={() => router.back()}>
            <Text>Close</Text>
          </Pressable>
        </View>
      );
    }

     

    그리고 /app/_layout.tsx 파일을 아래와 같이 modal 기능을 추가하여 Stack 컴포넌트 옵션을 수정해주자. modal 기능은 Stack 컴포넌트에서만 지원되기 때문에 아래 Stack 컴포넌트가 정의된 레이아웃(/app/_layout.tsx)이 적용되도록 모달 컴포넌트 (modal.tsx) 파일을 app 폴더 하위에 위치하도록 했다. 이제 add 탭 메뉴를 누르면 router.navigator('/modal')를 통해서 /modal 경로의 Modal 컴포넌트가 전체 레이아웃 (/app/_layout.tsx) 설정에 의해 모달 형태로 띄워진다.

    import { Stack } from 'expo-router';
    
    export default function RootLayout() {
      return (
        <Stack screenOptions={{ headerShown: false }}>
          <Stack.Screen name="(tabs)" />
          <Stack.Screen name="modal" options={{ presentation: 'modal' }} />
        </Stack>
      );
    }

     

    add 탭 메뉴로 모달 띄우기

     

    추가한 모달 기능과 같이 Stack 네비게이터와 Tab 네비게이터는 섞어서 복합적으로 사용한다. 현재 루트 경로(/)에 (tabs)와 modal이 Stack으로 쌓인 상태에서 Tab 네비게이터 내부에 다시 Stack 네비게이터가 또 들어갈 수도 있다.

     

    여기까지 주소는 바뀌되, 화면은 공유해서 쓰는 패턴 세 가지를 배웠다.

     

    4. 게시글 상세 페이지

    게시글 상세 페이지로의 라우팅은 파일 기반의 구조이기 때문에 구현이 간단하다. 이때 하단 네비게이션 바가 그대로 유지되기 위해서는 (tabs) 폴더 내에 게시글 상세 페이지 폴더 구조가 들어가 있어야 한다.

     

    게시물 상세 페이지 (프로필 탭 하이라이트)

     

    클론 코딩을 하는 이유 중 하나가 클론 코딩을 하면 모든 걸 똑같이 만들어야 된다. 똑같이 구현해 보는 과정에서 배울 수 있는 것이 많다. 클론코딩의 장점은 디테일한 것까지 억지로 따라하면서 어떻게 구현할까 고민을 해야 한다. 제로초님이 말하는 독학할 때와 회사 일을 할 때 실력 차이가 많이 나는 이유가 여기에 있다. 독할 할 때는 디테일한 사항을 대충 편의에 맞춰 무시하고 넘어가는 경우가 많지만, 회사 일을 할 때는 기획자가 짜놓은 그대로 구현해야 되는 경우가 많다. 이런 상황의 차이속에서 강제적으로 구현하다보면 별의별 꼼수나 진짜 원리를 제대로 파던가 하는 부수적인 공부효과를 얻을 수 있기 때문이라고 한다.

     

    스레드 앱을 보면 게시글에서는 네비게이션 바의 아이콘이 하이라이트 되는 것이 없다.

     

    스레드 게시물 상세 페이지

     

    게시물 상세 페이지에서 하이라이드가 되지 않기 위해서는 [username] 경로에서 벗어나야 가능하다. 그렇게 만들기 위해서는 그룹 폴더와 함께 <Tabs.Screen>에서 href를 null로 주면 해결된다. 네비게이션 바에서 이제 follwing 처럼 탭이 하나 더 생성되는 것도 아니고, [username]의 탭 아이콘이 하이라이트 되지 않는다.

    게시물 상세 페이지 (하이라이트된 탭 없음)

     

    탭 레이아웃이 경로를 어떻게 인식하는 지는 폴더 구조를 보면 된다. 이제 탭 레이아웃은 home, post, username, activity, add, search 탭만 존재한다고 인식한다. 그렇게 하므로써 post 탭과 username 탭을 구분할 수 있게 된다.

    탭 레이아웃이 인식하는 구조

     

    5. Feat Modal

    조건에 따라 내비게이션 다르게 하는 방법을 배워본다. 스레드 앱에서 로그인이 아닌 상태에서 add/activity/profile 탭을 눌러보면 지정 경로로 이동하지 않고 커스텀한 모달이 뜨게 된다. 탭 네비게이션에서 이런 조건에 따라 다르게 동작하도록 하는 기능도 구현해볼 수 있다.

    스레드 앱에서 Feat Modal

     

    탭 레이아웃에서 프래그먼트로 감싸주고 가장 하단에 Modal 컴포넌트를 넣어준다. 로그인 상태를 담는 isLoggedIn boolean 변수를 먼저 정의하고, 모달 제어에 사용할  isLoginModalOpen 상태와 이벤트 핸들러 함수를 만들어주면 Modal을 제어할 준비가 끝난다. 이제 모달에 간단한 스타일과 함께 닫기 버튼도 함께 넣어주면 조건에 따른 기본적인 모달 기능을 구현해줄 수 있다.

    export default function TabLayout() {
      const router = useRouter();
      const isLoggedIn = false;
      const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
    
      const openLoginModal = () => {
        setIsLoginModalOpen(true);
      };
    
      const closeLoginModal = () => {
        setIsLoginModalOpen(false);
      };
    
      return (
         ...
          <Modal
            visible={isLoginModalOpen}
            transparent={true}
            animationType="slide"
          >
            <View
              style={{
                flex: 1,
                justifyContent: 'flex-end',
                backgroundColor: 'rgba(0, 0, 0, 0.5)',
              }}
            >
              <View style={{ backgroundColor: 'white', padding: 20 }}>
                <Text>Login Modal</Text>
                <TouchableOpacity onPress={closeLoginModal}>
                  <Ionicons name="close" size={24} color="#555" />
                </TouchableOpacity>
              </View>
            </View>
          </Modal>
        </>
      );
      );
    }

     

    탭 레이아웃에 추가된 로그아웃 상태에서 Modal 기능

     

    탭 네비게이션의 특징은 home 탭에서 For you ('/') 경로라면 다른 탭을 눌렀다고 다시 돌아와도 home 탭은 동일한 경로를 가리킨다. 다시 말해 탭끼리 왔다 갔다 할 때는 기존의 탭의 상태가 유지된다. home 탭에서 '/following' 경로일 경우 add 탭을 이동을 하고 다시 home 탭에 돌아와도 home 탭 내부 경로 '/following' 상태가 유지가 된다.

    그대로 유지되는 홈 탭 내부 경로

     

    6. 뒤로가기 커스터마이징 (backBehavior)

    React Native를 하다보면 어이없는 부분이 있다. Home > search > profile > activity 순서로 history를 쌓아두어도 탭 네비게이션에서는 뒤로가기 버튼을 누르면 무조건 home('/')으로 이동한다. 이 특징은 탭 네비게이션의 기본 동작(initialRoute 설정)이며, 물론 커스터마이징이 가능하다. backBehavior의 타입은 아래와 같다.

    export type BackBehavior =
      | 'firstRoute'
      | 'initialRoute' // 무조건 home
      | 'order'        // Tab 역순
      | 'history'	   // 사용자 히스토리
      | 'fullHistory'
      | 'none';

     

    expo-router

    Expo-router에는 많이 사용하는 router.push, router.replace, router.navigate 가 있다. push는 히스토리에 탭 내무 경로까지 쌓이며, replace는 마지막 경로만 빼고 히스토리에 쌓이지 않는다. 그래서 탭 내에서 히스토리 경로를 안 쌓이게 하고 싶다면 router.replace를 사용하면 된다. 마지막으로 navigate는 중복만 제거하고 같은 탭에서 한 번씩은 히스토리에 남게 되는 동작을 수행한다. 스레드에서는 네비게이트를 거의 안 쓰고 거의 다 replace를 사용한다.

     

    Expo-router은 login 페이지에서도 사용할 수 있을 것이다. 로그인이 되어 있다면 home으로 보내주는 것이다.

    import { Redirect } from 'expo-router';
    import { Text, View } from 'react-native';
    
    export default function Login() {
      const isLoggedIn = false;
      if (isLoggedIn) {
        return <Redirect href="/(tabs)" />;
      }
      return (
        <View>
          <Text>Login</Text>
        </View>
      );
    }

     

    home, login, modal 은 굳이 _layout.tsx의 Stack.Screen에 안 적어도 기본적으로 추가가 되어 있다. 옵션을 붙여 커스터마이징을 하고 싶을 때만 작성해주면 된다. 커스터마이징 할 옵션이 없어서 Stack.Screen에 안 적었다고 해서 무시되는 것이 아니라 Expo-router가 파일 기반으로 Stack의 일부라고 파악을 한다.

    import { Stack } from 'expo-router';
    
    export default function RootLayout() {
      return (
        <Stack screenOptions={{ headerShown: false }}>
          <Stack.Screen name="(tabs)" />
          <Stack.Screen name="modal" options={{ presentation: 'modal' }} />
        </Stack>
      );
    }

     

    7. React Native Style

    React Native는 CSS를 그대로 사용하기 때문에 Flex로 디자인한다고 생각하면 된다. Flex 기반으로 하되, 1가지 차이점은 웹에서는 flex-direction이 기본값이 row인데 반해, React Native는 flex-direction 기본값이 column으로 되어 있다.

     

    StyleSheet

    React Native에 CSS를 적용하는데 최적화를 하고 싶다고 하면 보통 컴포넌트 하단에 styles를 따로 정의해준다. 이렇게 따로 정의해주면 styles.container에 특정한 id가 붙게 된다. 그 id를 <View> 컴포넌의 style에 넣어주는 것이다. 그러면 React Native가 아이디를 캐싱을 하거나 최적화를 수행한다. 그래서 컴포넌트가 리렌더링 되더라도 기존에 객체 리터럴이 들어가 있는 경우에 비해 객체를 생성할 필요가 없기 때문에 다시 계산하는 과정에서 성능에 영향이 끼칠 수 있는 요인이 사라진다. 즉, StyleSheet를 사용하면 React Native가 알아서 최적화를 해준다고 보면 된다.

    import { StyleSheet, View } from 'react-native';
    
    export default function Index() {
      ...
      return (
        <View style={styles.container}>
        ...
      );
    }
    
    const styles = StyleSheet.create({
      container: {
        flex: 1,
      },
    });

     

    react-native-safe-area-context

    안드로드 스튜디오 애뮬레이터를 보면 카메라 부분에 텍스트가 가려보인다. 아이폰의 경우에는 노치 부분에 해당할 것이다. 이런 못쓰는 부분을 제외할 수 있다. 진짜 화면으로 쓸 수 있는 부분인 Safe Area 를 계산하기 위한 라이브러리가 존재한다. 이 safe area를 계산하는 라이브러리 React Native Safe Area Context 를 적용해본다.

    위 공식문서 페이지로 이동하면 React Native Safe Area Context 외에도 수많은 라이브러리가 왼쪽 사이드 바에 보인다. 해당 라이브러리들만 사용하면 Expo Go 상태를 유지할 수 있다. 여기 있는 라이브러리들만 출시할 때까지 사용한다면 Expo Go 상태를 유지하여 빌도도 배포도 쉽게 할 수 있다. 그래서 라이브러리를 찾아볼 때 항상 이곳의 라이브러리들 중에서 찾아보는 것이 좋다.

    npx expo install react-native-safe-area-context

     

    위 CLI 명령어를 보면 버전 정보가 없다. 라이브러리를 설치할 때 항상 염두해야 하는 것이 expo install로 라이브러리들을 설치해주어야 한다. Expo에도 SDK 버전이 있다. 그리고 SDK 52버전과 53버전 각각에 맞는 React Native Safe Area Context가 따로 있다. 이런 SDK와 라이브러리 간의 버전 불일치를 Expo가 알아서 막아준다. 그렇기 때문에 항상 npx expo install로 설치한다.

     

    보통 <View> 컴포넌트 대신 <SafeAreaView>를 사용하면 알아서 상하단에서 못쓰는 부분을 제외하고 화면이 렌더링 된다. 하지만 <SafeAreaView>가 동작 안하는 기종이 있는 경우 useSafeAreaInsets 훅을 사용해서 대응해 준다.

    import {
      SafeAreaView,
      SafeAreaProvider,
      SafeAreaInsetsContext,
      useSafeAreaInsets,
    } from 'react-native-safe-area-context';

     

    SafeAreaView 컴포넌트가 적용이 안될 경우, useSafeAreaInsets를 사용하여 상하좌우로 얼마나 간격(insets)을 띄워 주어야 하는 알 수 있다. 그리고 styel에 배열 형태로 추가 스타일을 넣어준다. 보통 컴포넌트 안에서 안 바뀌는 스타일인 정적인 스타일은 스타일 시트로 만들어 두고, 바뀌는 스타일 속성들은 객체 리터럴로 작성해준다. 그러면 알아서 StyleSheet는 최적화가 되고 바뀌는 부분은 리렌더링을 위해 다시 생성되는 식으로 가져간다.

    ...
    import { useSafeAreaInsets } from 'react-native-safe-area-context';
    
    export default function Index() {
      ...
      const insets = useSafeAreaInsets();
    
      console.log('pathname', pathname);
      console.log('insets', insets);
    
      return (
        <View 
          style={[
            styles.container,
            { paddingTop: insets.top, paddingBottom: insets.bottom }
           ]}>
          <View style={styles.tabContainer}>
            <View style={styles.tab}>
        ...

     

    Safe Area Context를 사용한 결과

     

    8. CSS와의 다른 점

    React Native style도 최신 CSS를 모두 지원하지만 Pure CSS와 다른 점이 존재한다. 결론만 말하면, React Native의 Style은 매우 간단한 CSS라고 생각하면 된다. 참고로 React Native에서 준비해둔 컴포넌트들이 있다. 이 컴포넌트와 그 속성들 위주로 공식 문서를 참고해서 공부하면 좋다.

     

    차이점

    • color: 버튼에 color 속성을 넣어도 자식 요소인 Text 컴포넌트에 적용이 되지 않는다. 그렇기 때문에 color 속성은 정확히 해당 Text 컴포넌트에 적용해줘야 한다. 이 부분은 웹이랑 같이 하면서 계속 헷갈리는 부분이다.
    • DP 단위: React Native는 pixel 단위가 아닌 DP(DPI, 기기 독립 픽셀) 단위를 사용한다. DP 또는 DPI를 사용하면 대부분의 기기에 서 비슷하게 나온다. 기기의 해상도를 따라가는 것이 아니라 화면 크기 (예시, 1080x1920)에 따라서 따라가기 때문에 모든 기기에서 다 비슷하게 나오는 효과를 가질 수 있다.
    • 선택자 없음
    • 우선순위 없음: 어차피 인라인 CSS이기 때문에 따질 필요없음
    • 미디어 쿼리 없음: 그렇기 때문에 if 조건문과 Dimensions 라이브러리를 사용해야 함
    • 가상요소 없음: 예를들면 hover, before, after 없음.

     

    BackDropFilter

    살짝 불투명하게 뒤에 컨텐츠가 비치는 백드롭 필터 기능이 React Native에서 제대로 지원이 안되는 경우가 있다. 이럴 때는 헤더 부분에 해당하는 부분을 Expo BlurView 라이브러리의 <BlurView>를 사용해주면 된다. 이 BlurView 컴포넌트 뒤에는 다 흐려지는 처리가 된다. 이 BlurView는 insection으로 강도를 조절할 수도 있다.

    스레드 게시판의 backDropFilter 기능

    패키지 설치는 아래 명령어를 통해서 진행한다.

    npx expo install expo-blur

     

    화면의 너비

    화면의 너비를 알고 싶다면 react-native 내부 Dimensions를 사용하면 된다. 만약 pixel도 알고 싶다면 dp를 pixel로 변환하는 라이브러리 pixelRatio를 사용할 수 있다.

    import { Dimensions, ..., pixelRatio } from 'react-native';
    
    ...
    
    export default function Index() {
      ...
      const { width, height } = Dimensions.get('window'); // 화면 너머: 411.42857142857144dp, 높이: 914.2857142857143dp
      console.log(`화면 너비: ${width}dp, 높이: ${height}dp`);
      console.log(
        `화면 너비: ${width * PixelRatio.get()}px, 높이: ${
          height * PixelRatio.get()
        }px`
      );
    
    ...

     

    Pressable 컴포넌트와 애니메이션의 적용

    앱이다 보니 애니메이션이 들어간 간단한 인터랙션이 많이 쓰이는 만큼 React native에서는 애니메이션을 지원한다. 네비게이션 바에 있는 탭에 애니메이션을 적용하여 커스터마이징을 진행해보자.

    이번에는 TouchableOpacity가 아닌 Pressable 컴포넌트를 사용한다. 이전 버튼에는 TouchableOpacity를 사용했지만, 같은 버튼이면서 Pressable이 조금 더 커스터마이징하기 쉬운 컴포넌트이기 때문이다. 이벤트 리스너도 대부분 있으며, 길게 누르는 onLongPress, onPointerLeave 와 같은 더 많은 이벤트들을 지원한다. 그래서 개발자들이 버튼을 개발할 때 더 다양한 인터랙션을 원한다면 Pressable 컴포넌트를 선택하는 것이 좋다. 또한 기본적으로 안드로이드 버튼은 눌렀을 때 물결처럼 퍼져나가는 android ripple 효과를 가지고 있이며, Pressable에서는 이 효과를 없애는 속성을 지원한다.

    Pressable 버튼에 새로운 애니메이션을 적용할 경우 Animated 컴포넌트를 적용하면 된다. Animated는 기본적으로 .View를 가져와서 <div> 태그와 유사하게 사용한다. Animated는 어떤 숫자를 변화시킨다라고 개념을 가지고 접근하면 쉽다. 버튼의 경우 버튼을 잠깐 크게 키웠다가 줄이는 애니메이션을 적용한다면, CSS에서 transform의 scale을 키웠다가 줄이는 것이 된다. 조의할 점은 scale에 들어가는 값은 일반 number 타입이 아닌 Animated.Value 값이어야 Animated에서 인식을 한다.

    이벤트 리스너에는 onPress는 일반 적인 클릭이며, onPressIn은 마우스를 눌렀을 때이고, onPressOut은 마우스 눌렀다가 손가락을 땠을 때이라고 생각하면 된다.

     

    일반적인 Animated 진행 효과

    속성에 toValue는 초기값이며, friction은 마찰이라는 뜻 그대로 높을 수록 spring 효과가 적다. friction은 기본 4 정도가 적다하다. useNativeDriver는 일반적으로 true로 설정해두는 것이 좋다. 이 부분을 true로 설정해두어야 JavaScript 스레드가 아닌 GPU를 사용하기 때문에 헤비한 작업인 애니메이션로 인해 성능저하를 줄일 수 있다. 애니메이션 효과를 빠르게 적용하고 싶은 경우 speed를 설정할 수 있다. 하지만 speed와 friction은 함께 사용할 수 없는 속성이다.

    • spring: 처음 크기가 1이라면, 1 > 2.1 > 1.9 > 2 형태로 변하면서 스프링처럼 크기가 튕기는 느낌이 난다.
    • decay: 처음 크기가 1이라면, 1 > 1.5 > 1.8 > 1.9 > 2 형태로 처음에는 빨랐다가 점점 느려지는 변화를 보인다.
    • timing: 사용자가 지정한 대로 커스터마이징이 가능하다.

    애니메이션을 배열에 넣어 다중으로 돌리는 메서드

    • delay: 순차적으로 설정된 delay 간격에 맞춰 실행된다.
    • parallel: 동시 실행
    • sequence: 순차 실행
    • stagger: parallel과 delay를 섞어둔 설정이다. 정해진 시간마다 하나씩 실행된다. 앞에 애니메이션이 끝나지 않아도 뒤에 있는 애미메이션이 시작할 수 있다. 그래서 일정한 간격을 두고 하나씩 실행시키는 것이 스태거이다.
    const AnimatedTabBarButton = ({
        children,
        onPress,
        style,
        ...restProps
      }: BottomTabBarButtonProps) => {
        const scaleValue = useRef(new Animated.Value(1)).current;
    
        const handlePressOut = () => {
          Animated.sequence([
            Animated.spring(scaleValue, {
              toValue: 1.2,
              useNativeDriver: true,
              // friction: 100,
              speed: 200,
            }),
            Animated.spring(scaleValue, {
              toValue: 1,
              useNativeDriver: true,
              // friction: 100,
              speed: 200,
            }),
          ]).start();
        };
    
        // Extract ref from restProps to avoid type conflicts
        const { ref, ...pressableProps } = restProps;
    
        return (
          <Pressable
            {...pressableProps}
            onPress={onPress}
            onPressOut={handlePressOut}
            style={[
              { flex: 1, justifyContent: 'center', alignItems: 'center' },
              style,
            ]}
            // Disable Android ripple effect
            android_ripple={{ borderless: false, radius: 0 }}
          >
            <Animated.View style={{ transform: [{ scale: scaleValue }] }}>
              {children}
            </Animated.View>
          </Pressable>
        );
      };

     

    조금 더 복잡한 애니메이션을 구현하고 싶다면 유명한 expo 라이브러리인 react-native-reanimated를 사용할 수 있다. 또 하나 추천하는 라이브러리로는 lottie-react-native가 있다. 이 라이브러리는 애프터 이펙트 같은 효과를 렌더링 해주기 때문에 추천한다. 로띠는 React Native 뿐만 아니라 웹에서도 애니메이션 효과를 만들어 적용해 볼 수 있다. 참고로 Lottie는 동작이 복잡하기 때문에 그 과정을 JSON으로 정의한 뒤에 재생하는 식으로 활용한다. 기본적으로 React Native에서 공급하는 Animated를 사용하고, 그것으로 부족하다 싶으면 react-native-reanimated를, 진짜 화려한 애니메이션 효과를 넣고 싶다면 lottie-react-native를 선택하면 되겠다.

     

    9. 게시글 작성 기능 구현

    게시글 작성을 위한 기능 구현과 스타일링을 해본다. 해당 기능은 modal.tsx 파일에서 진행한다.

     

    hairlineWidth

    Footer에서 보통 border를 이용해서 얇은 경계를 만들어주고는 한다. 웹에서는 1 이하로 지원 안 되는 매우 얇은 선을 표현할 수 있는 bortTopWidth: StyleSheet.hairlineWidth (머리카락 굵기)를 지원한다.

      footer: {
        ...
        borderTopWidth: StyleSheet.hairlineWidth,
        borderTopColor: '#ccc',
        ...
      },

     

     

    FlatList 컴포넌트

    스레드 앱을 보면 새로운 글을 작성할 때, 동시에 여러개의 글을 작성할 수 있도록 아래로 나열되도록 배치가 된다. 해당 기능을 React Native에서 구현을 하기 위해 FlatList 컴포넌트를 사용한다. FlatList는 Viewport 상에 보이지 않는 요소들의 경우 메모리에 남겨두고 렌더링은 수행하지 않는 최적화가 적용되어 있다. 그렇기 때문에 FlatList 컴포넌트를 사용해서 구현하는 것이 훨씬 성능에 유리하다. 

     

    React Native에서 스크롤 되는 3가지 요소

    • ScrollView: 단순히 스크롤만을 위해 추가
    • FlatList: 스크롤 내부에 리스트가 들어갈 경우 사용
    • SectionList
    <FlatList
      data={threads}
      keyExtractor={(item) => item.id}   // 고유한 값
      renderItem={renderThreadItem}
      ListFooterComponent={ListFooter}
      style={styles.list}
      contentContainerStyle={{ paddingBottom: 100, backgroundColor: "#ddd" }}
      keyboardShouldPersistTaps="handled"
    />

     

    React를 사용할 때 JSX에서 요소들을 배열의 map 메서드를 사용할 때 key 속성을 사용해서 고유한 값을 넣어준다. 그것처럼 FlatList에서는 KeyExtractor에 고유한 값을 추출해서 키로 사용한다. 꾸밀 수 있는 속성은 여러가지가 있다. FlatList 자체를 꾸미는 경우에는 style 속성을 사용하고, Item 자체를 꾸미는 것은 renderItem을 사용, Footer의 경우 ListFooterComponents에서 꾸미면 된다. 헤더, 아이템, 푸터를 감싸고 있는 Content Container를 꾸미고 싶을 경우에는 contentContainerStyle을 사용한다.

    • keyExtractor: 렌더링 시 사용되는 고유값들
    • (ListHeaderComponents) : FlatList에서 3단 구조(header, item, footer) 중 Header를 지원하는 속성
    • renderItem: 렌더링 되는 데이터들을 화면에 어떻게 보여줄 것인지 item을 정의하는 속성
    • ListFooterComponents: FlatList에서 3단 구조(header, item, footer) 중 footer를 지원하는 속성
    • style: FlatList를 꾸미는 스타일 속성
    • contentContainerStyle: FlatList 3단 구조를 감싸는 Container를 지원하는 속성
    • keyboardShouldPersistTaps: 입력 태그에서 키보드 제어 속성
      • never: 입력 영역 밖을 누르면(tap) 키보드가 닫힘
      • always: 키보드가 자동으로 닫히지 않음
      • handle:자식요소가 탭을 처리하거나, 상위 View가 탭을 캡쳐한 경우 키보다가 자동으로 닫히지 않음

     

    Thread 타입

    인터페이스로 정의한 Thread 타입의 이미지 속성 타입이 string[] 으로 정의되어 있다. 웹 개발자라면 이미지라면 바이너리, 블록파일, 버퍼 형태의 타입이어야 하는 게 아닐까 생각할 수 있는 부분이다. 하지만 React Native에서는 파일 경로만 써주면 해당 이미지를 사용할 때 알아서 불러와서 사용한다. 그래서 파일이라고 해서 블록, 버퍼, 파일 객체의 타입이 아닌 그냥 경로인 string 타입을 써주면 된다.

    interface Thread {
      id: string;
      text: string;
      hashtag?: string;
      location?: [number, number];
      imageUris: string[];
    }

     

    코드가 너무 길어서 다 붙여넣을 수는 없지만, Add 버튼을 눌렀을 때 게시글 작성 모달의 아래와 같은 형태로 구현했다.

    게시글 작성 모달

    해시태그 추가 기능

    스레드 게시글에는 해시태그를 1개 추가할 수 있는 기능이 있다. 이 기능과 UI를 구현해본다.

    해당 기능 구현에 내용이 많아 다음 포스팅에 따로 정리한다.

     

     

     

     

    .

    .

    .

    .

    .

    .

    .

    코드 저장소

    https://github.com/redcontroller/threads-clone/blob/main/app/modal.tsx

     

    threads-clone/app/modal.tsx at main · redcontroller/threads-clone

    제로초 React native 학습 레포. Contribute to redcontroller/threads-clone development by creating an account on GitHub.

    github.com

     

     

Designed by Tistory.