ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [RN 강의] 편리한 Expo 라이브러리로 기능 구현
    강의노트/React Native 2025. 9. 8. 11:52

     

    1. expo-location

    이제 게시글 작성 시에 위치정보를 사용하는 기능을 추가한다. 이를 위해 위치정보를 다루기 위한 Expo에서 제공하는 라이브러리를 설치한다.

    npx expo install expo-location

     

    위치정보 액세스 허용 문의창

     

    위치정보는 사용자의 권한 허용이 필요하다. 우선 관련된 app.json에 추가해야 하는 설정을 공식 문서에 있는 대로 추가한다. app.json이 수정되면 프로젝트를 다시 빌드를 해줘야 한다. 기존 실행 중인 React Native 서버는 중지하고 다시 npm run android 명령어로 다시 빌드 후 실행준다.

    추가된 설정은 위치정보를 항상 사용 중일 때 허용하는 권한이며, 사용자에게 보여지는 문구는 커스터마이징이 가능하다. 앱이 사용 중일 때는 권한을 받기도 쉽고 설정도 어렵지 않지만, 백그라운드에서의 위치권한 정보 허용을 받기 위해서는 android와 ios별로 추가적인 설정이 더 필요해보인다.

    {
      "expo": {
        "plugins": [
          [
            "expo-location",
            {
              "locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
            }
          ]
        ]
      }
    }

     

    사용자에게 위치정보 권한을 받지 못했을 때도 있을 것이다. 이를 대비해서 사용자가 수동으로 설정할 수 있도록, 설정창을 열어주는 React Native의 Linking을 사용한다. 갑자기 설정창이 열리면 사용자가 놀랄 수 있으니, <Alert> 컴포넌트를 사용하여 설정창이 열리기 전 권한 설정이 필요하다는 내용을 안내해주도록 한다.

    <Alert> 컴포넌트는 3가지 옵션값을 넣었다. 먼저 메시지를 제목과 상세내용을 첫번쨰와 두번째 옵션에 넣을 수 있으며, 마지막 옵션에는 알림창의 'cancel'과 'Open Settings' 버튼 설정을 넣어준다. 

    import * as Location from 'expo-location';
    
      const getMyLocation = async (id: string) => {
        let { status } = await Location.requestForegroundPermissionsAsync();
        console.log('getMyLocation', status);
        if (status !== 'granted') {
          Alert.alert(
            'Location permission not granted',
            'Please grant location permission to use this feature',
            [
              {
                text: 'Open settings',
                onPress: () => Linking.openSettings(),
              },
              {
                text: 'Cancel',
              },
            ]
          );
          return;
        }

     

    위 코드가 동작하는 화면은 아래 사진과 같다. 초기 권한 설정에는 아래와 같이 위치 정확도를 위해 활성화 해야 하는 추가 권한 요청이 들어온다.

    설정창을 열기 전 안내하는 Alert 기능과 추가 권한 설정

     

    추가로 알아두면 좋은 expo-location에는 많은 함수들

    • watchPositionAsync(): 이 함수를 사용하면 앱 사용자가 이동할 때도 실시간으로 위치정보를 업데이트할 수 있다. 두번째 인자인 callback에서 위치를 받아 상태를 업데이트 해주면 된다. 하지만 해당 함수를 사용하기 위해서는 애플리케이션을 끄거나 백그라운드로 가면 위치정보 권한을 잃어버릴 수 있기 때문에 항상 백그라운드 위치정보 권한을 확보해야 한다.
    • startGeofencingAsync(): 이 함수는 설정 범위 안에 들어오거나 나갈 때 이벤트가 발생하는 기능을 구현할 수 있다.
    • watchHeadingAsync(): 사용자의 방향을 판단할 수 있는 기능을 구현할 수 있다.
    • geocodeAsync(): 주소를 위도/경도로 바꾼다.
    • reverseGeocodeAsync(): 위도/경도를 주소로 바꾼다. (미국 기준)
        const address = await Location.reverseGeocodeAsync({
          latitude: location.coords.latitude, // 37.53
          longitude: location.coords.longitude, // 127.02
        });
        console.log('address', address);
        
        // address [{"city": "Mountain View", "country": "미국", "district": null, "formattedAddress": "Google Building 40, 1600 Amphitheatre Pkwy, Mountain View, CA 94043 미국", "isoCountryCode": "US", "name": "Google Building 40", "postalCode": "94043", "region": "California", "street": "Amphitheatre Parkway", "streetNumber": "1600", "subregion": "Santa Clara County", "timezone": null}]

     

    2. 이미지 업로드 기능

    이미지 업로드 기능에는 사용자의 저장된 이미지를 올리는 기능과, 카메라를 통해 바로 찍은 사진을 올릴 수 있는 기능으로 구분하여 구현한다. 관련된 Expo 라이브러리는 MediaLibraryImagePicker가 있다. 우선 구현할 기능이 카메라나 갤러리에서 사진을 가져오는 기능을 구현하기 때문에 ImagePicker를 사용한다.

     

    expo-media-library

    Expo ImagePicker 라이브러리는 기본 System UI를 제공해준다. Media Library는 이미지보다 좀 더 큰 범주인 파일이라고 생각하면 된다. ImagePicker는 통해서 카메라나 갤러리에서 이미지를 가져오고, MediaLibrary로 저장하는 기능을 구현에 쓰인다고 새악하면 된다.

     

    expo-image-picker

    사진 그리고 카메라 기능을 사용하기 때문에 때문에 관련된 사용자 권한 허용이 필요하다. location 라이브러리와 동일하게 app.json의 plugins에 등록한다.

    {
      "expo": {
        "plugins": [
          [
            "expo-image-picker",
            {
              "photosPermission": "The app accesses your photos to let you share them with your threads.",
              "cameraPermission": "The app accesses your camera to let you share photos and videos with your threads."
            }
          ]
        ]
      }
    }

     

    위치정보와 유사한 형태로 권한을 얻지 못했을 때, 수동설정할 수 있도록 설정창을 Linking 해준다. 권한을 얻었을 경우에는 이미지라이브러리 또는 카메라 기능을 가져오도록 한다. F12를 통해서 launchImageLibraryAsync의 타입을 확인해볼 수 있다. mediaTypes로 설정한 livePhotos는 0.5초 정도 움직이는 이미지이다.

    • allowsEditing: 이미지를 크롭하거나 기울기 조정을 할 수 있는 편집창을 띄울 수 있다.
    • aspect: 이미지 비율 편집
    • quality: 이미지 압축 시 옵션
    • mediaTypes: MediaType | MediaType[]
    • exif: 메타 데이터 같은 것이며, 이미지 데이터 내부의 GPS 정보나 카메라 찍을 때 왼쪽/오른쪽 가로 정보 등
    • base64: 이미지를 Base64 스트링으로 받을지 여부 (React Native는 경로만 필요함)
    • allowsMultipleSelection: 다중 이미지 선택 여부
    • selectionLimit: 한번에 몇개를 고를 것인지 
    import * as ImagePicker from 'expo-image-picker';
    
      const pickImage = async (id: string) => {
        let { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
        console.log('pickImage', status);
        if (status !== 'granted') {
          Alert.alert(
            'Image permission not granted',
            'Please grant image permission to use this feature',
            [
              { text: 'Open settings', onPress: () => Linking.openSettings() },
              { text: 'Cancel' },
            ]
          );
          return;
        }
        // 권한 획득
        let result = await ImagePicker.launchImageLibraryAsync({
          mediaTypes: ['images', 'livePhotos', 'videos'],
          allowsMultipleSelection: true,
          selectionLimit: 5,
        });
        console.log('Image result', result);
        if (!result.canceled) {
          setThreads((prevThreads) =>
            prevThreads.map((thread) =>
              thread.id === id
                ? {
                    ...thread,
                    imageUris: thread.imageUris.concat(
                      result.assets?.map((asset) => asset.uri) ?? []
                    ),
                  }
                : thread
            )
          );
        }
      };
    
      const takePhoto = async (id: string) => {
        let { status } = await ImagePicker.requestCameraPermissionsAsync();
        console.log('takePhoto', status);
        if (status !== 'granted') {
          Alert.alert(
            'Camera permission not granted',
            'Please grant camera permission to use this feature',
            [
              { text: 'Open settings', onPress: () => Linking.openSettings() },
              { text: 'Cancel' },
            ]
          );
          return;
        }
        let result = await ImagePicker.launchCameraAsync({
          mediaTypes: ['images', 'livePhotos', 'videos'],
          allowsMultipleSelection: true,
          selectionLimit: 5,
        });
        console.log('Photo result', result);
        if (!result.canceled) {
          setThreads((prevThreads) =>
            prevThreads.map((thread) =>
              thread.id === id
                ? {
                    ...thread,
                    imageUris: thread.imageUris.concat(
                      result.assets?.map((asset) => asset.uri) ?? []
                    ),
                  }
                : thread
            )
          );
        }
      };

     

    권한 허용을 받은 상태에서 이미지를 선택했을 때, 출력되는 result를 보면 객체에 키가 url인 문자열 값을 가져와서 게시글의 이미지 uri롤 등록해줘야 한다. 안드로이드 애뮬레이터에서 카메라 기능은 귀여운 캐릭터가 움직이는 모습을 찍을 수 있는 카메라 촬영 효과를 제공한다. 다만 찍은 사진이 갤러리에 저장되지는 않는다.

    안드로드 애뮬레이터 속 카메라 기능

      

    3. 이미지 저장 및 제거 기능

    이미지 저장

    카메라로 촬영한 이미지를 저장하는 기능을 구현하기 위해서는 앞서 언급한 MediaLibrary가 필요하다. 이제 미디어 라이브러리를 통해서 이미지를 저장해보자. MediaLibrary관련 설정을 app.json에 추가해준다.

    {
      "expo": {
        "plugins": [
          [
            "expo-media-library",
            {
              "photosPermission": "Allow $(PRODUCT_NAME) to access your photos.",
              "savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
              "isAccessMediaLocationEnabled": true
            }
          ]
        ]
      }
    }

     

    아래 코드는 MediaLibrary 권한을 요청하고, 권한을 받았을 때만 이미지를 갤러리에 저장하는 기능이다.

        // 사진 저장 권한 획득 시 사진 저장
        status = (await MediaLibrary.requestPermissionsAsync()).status;
        if (status === 'granted' && result.assets?.[0].uri) {
          MediaLibrary.saveToLibraryAsync(result.assets?.[0].uri);
        }

     

    지금의 Expo를 쓰기 전에는 편의 기능을 제공하는 라이브러리 기능들이 없어서 이미지가 다 깨지곤 했었다. 그래서 ImagePicker와 관련된 네이티브 코드를 직접 수정해서 배포하고는 했다고 한다. 최신 Expo에는 이런 기능들이 전부 라이브러리로 갖춰져 있어서 웬만하면 네이티브를 안 건들고 편하게 애플리케이션 기능개발을 수행할 수 있게 되었다.

     

    이미지 제거

    이미지를 제거하는 기능은 취소(x) 버튼을 눌렀을 때 게시물의 해당 uri의 이미지를 제거해주면 된다.

      const removeImageFromThread = (id: string, uriToRemove: string) => {
        setThreads((prevThreads) =>
          prevThreads.map((thread) =>
            thread.id === id
              ? {
                  ...thread,
                  imageUris: thread.imageUris.filter((uri) => uri !== uriToRemove),
                }
              : thread
          )
        );
      };

     

    4. 모달 내부에서 커스터마이징 모달 구현

    게시글 작성 페이지 하단 'Anyone can reply & quote'가 기본 설정된 부분에 대한 모달이 필요하다. 우리는 이름이 Modal인 컴포넌트를 구현하고 있기 때문에, React Native Core의 Modal을 import 할 때는 이름을 바꿔서 사용하면 된다.

    import {
      ...,
      Modal as RNModal,
      ...
    } from 'react-native';
    
      ...
    
      const [replyOption, setReplyOption] = useState('Anyone');
      const replyOptions = ['Anyone', 'Profiles you follow', 'Mentioned only'];
    
      ...
    
              <RNModal
                visible={isDropdownVisible}
                transparent={true}
                animationType="fade"
                onRequestClose={() => setIsDropdownVisible(false)}
              >
                <Pressable
                  style={styles.modalOverlay}
                  onPress={() => setIsDropdownVisible(false)}
                >
                  <View
                    style={[
                      styles.dropdownContainer,
                      { bottom: insets.bottom + 30 },
                    ]}
                  >
                    {replyOptions.map((option) => (
                      <Pressable
                        key={option}
                        style={[
                          styles.dropdownOption,
                          option === replyOption && styles.selectedOption,
                        ]}
                        onPress={() => {
                          setReplyOption(option);
                          setIsDropdownVisible(false);
                        }}
                      >
                        <Text
                          style={[
                            styles.dropdownOptionText,
                            option === replyOption && styles.selectedOptionText,
                          ]}
                        >
                          {option}
                        </Text>
                      </Pressable>
                    ))}
                  </View>
                </Pressable>
              </RNModal>

     

    Modal 컴포넌트를 사용해 구현된 커스텀 모달의 동작

    커스텀 모달 동작

     

    5. 로그인 기능

    로그인 기능을 구현하려고 하는데 서버가 없는 상황이다. 이때 대처방법으로는 서버를 직접 만들거나, 서버를 모킹하는 방법이 있다. 모킹은 서버를 흉내내는 가까 서버를 만드는 방법이다. 예전에는 MSW가 추천되었는데 miragjs라는 더 간단한 라이브러리로 진행한다. 이렇게 서버 모킹을 하면 백엔드 개발자가 API 완성할 때까지 기다릴 필요가 없다. 가짜로 API를 만든 후에 실제 API 서버로 교체하면 된다.

     

    서버 모킹: miragejs 설치 및 세팅

    miragejs를 설치하고 세팅을 수행한다. miragejs 설정은 전체 공통 파일인 레이아웃에서 진행한다. React Native는 package.json의 "main" 부분을 실행한다. 보통 일반 프로젝트는 index.js 파일인데, Expo 기반의 React Native 프로젝트는 "expo-router/entry"로 되어 있다. 이는 Expo Router Entry라는 파일이 알아서 app 폴더를 보고 레이아웃과 chilren, 라우터를 골라서 렌더링을 수행한다. 해당 부분을 커스터마이징 하여 사용해본다.

    # Using npm
    npm install --save-dev miragejs
    
    # Using Yarn
    yarn add --dev miragejs
    {
       "name": "Threads-clone",
       "main": "index.ts",       // default: "expo-router/entry"
       ...

     

    로그인 기능에 사용할 miragejs 설정을 위해 index.ts 파일에 개발모드(__DEV__)일 때만 실행되도록 한다.

    import 'expo-router/entry';
    import { createServer, Server } from 'miragejs';
    
    declare global {
      interface Window {
        server: Server;
      }
    }
    
    if (__DEV__) {
      if (window.server) {
        window.server.shutdown();
      }
      window.server = createServer({
        routes() {
          this.post('/login', (schema, request) => {
            const { username, password } = JSON.parse(request.requestBody);
    
            if (username === 'user0' && password === '1234') {
              return {
                accessToken: 'access-token',
                refreshToken: 'refresh-token',
                user: {
                  id: 'user0',
                },
              };
            } else {
              return new Response(
                JSON.stringify({ message: 'Invalid credentials' }),
                {
                  status: 401,
                  headers: { 'Content-Type': 'application/json' },
                }
              );
            }
          });
        },
      });
    }

     

    일단은 간단하게 로그인 버튼만 누르면 코드에 기본갑으로 넣어둔 계정정보로 로그인이 되도록 해본다. 아래와 같이 로그인 버튼에서 사용될 로그인 API을 호출하는 fetch 함수를 만들어본다. React Native에서도 요청을 보내는 fetch 함수가 존재한다. '/login'으로 fetch하면 index.ts에서 요청을 받을 수 있다.프로그래밍할 때 요령으로, 항상 틀리는 것부터 먼저 해보는 것이 좋다. 비밀번호를 틀리도록 해서 로그인 기능을 테스트 해본다. 주의할 점은 Axios와 달리 fetch에서는 400 응답이라도 성공한 걸로 판단한다. 그래서 then으로 400 이상의 경우에 적절하게 문구를 작성해서 사용하면 된다.

      const onLogin = () => {
        console.log('login');
        fetch('/login', {
          method: 'POST',
          body: JSON.stringify({
            username: 'user0',
            password: '1235', // '234'
          }),
        })
          .then((res) => {
            console.log('res', res, res.status);
            if (res.status >= 400) {
              return Alert.alert('Error', 'Invalid credentials');
            }
            return res.json();
          })
          .then((data) => {
            console.log('data', data);
          })
          .catch((error) => {
            console.error(error);
          });
      };

     

    로그인 응답정보 저장하기: AsyncStorage, SecureStorage

    Expo에서는 로그인 성공시 들어오는 데이터를 저장하는 저장소를 제공한다. 대표적으로 AsyncStorage가 있다. 이 저장소는 비밀 키들을 저장하는데는 부적절하며 공개된 값들을 앱에 남겨두고 싶을 때 사용한다.

    npx expo install @react-native-async-storage/async-storage

     

    비밀값을 저장할 때는 SecureStore를 사용한다. 비밀값이라 하면, 액세스 토큰이나 리프레쉬 토큰을 말한다. SecureStore는 저장하는 값을 운영체제에서 제공하는 보안키 저장소에 안전하게 저장을 한다.

    npx expo install expo-secure-store

     

    로그인 fetch 함수에 데이터를 저장하는 기능을 추가해보자. 로그인 성공 시에 용도를 구분해서 적절한 저장소에 데이터를 저장한다.

    import AsyncStorage from '@react-native-async-storage/async-storage';
    import * as SecureStore from 'expo-secure-store';
    
    ...
    
      const onLogin = () => {
        console.log('login');
        fetch('/login', {
          method: 'POST',
          body: JSON.stringify({
            username: 'user0',
            password: '1235', // '234'
          }),
        })
          .then((res) => {
            console.log('res', res, res.status);
            if (res.status >= 400) {
              return Alert.alert('Error', 'Invalid credentials');
            }
            return res.json();
          })
          .then((data) => {
            console.log('data', data);
            return Promise.all([
              SecureStore.setItem('accessToken', data.accessToken),
              SecureStore.setItem('refreshToken', data.refreshToken),
              AsyncStorage.setItem('user', JSON.stringify(data.user)),
            ]).then(() => {
              router.push('/(tabs)');
            });
          })
          .catch((error) => {
            console.error(error);
          });
      };

     

    React Native (Expo Go) 호환 라이브러리 검색법: React Native Directory

    상태관리 라이브러리 중에 valtio가 있다. 리덕스를 사용할 때, immer 라이브러리를 쓰는 것과 마찬가지로 불변성을 안지켜도 알아서 지켜주는 편리한 라이브러리라고 한다. 그런데 이 valtio 라이브러리가 React Native와 호환이 잘 안된다는 이야기가 있다. 이렇듯 라이브러리를 선정할 때 호환되는지 여부가 궁금한 경우가 있는데, 간단하게 알아볼 수 있는 사이트가 React Native Directory 이다. React Native Directory에는 유명한 라이브러리들이 대상이 되지만, 검색 결과가 나오지 않는다고 꼭 React Native와 호환이 안 된다는 의미는 아니다. 하지만 검색 결과에 들어 있다면 확실하게 React Native에 설치하여 사용할 수 있다.

    라이브러리 검색 결과를 볼 때, 본인이 최신 아키텍처를 사용하고 있다면 'New Architecture' 지원 태그가 있는지 확인해봐야 한다. 그리고 Expo Go를 사용하고 있다면 검색 필터(Compatibility)에 'Works with Expo Go'를 체크해서 검색하자.

    Zustand 검색 결과

     

    로그인 상태 공유하기: Context API

    로그인이 성공하면 다양한 경로의 컴포넌트에 상태를 공유해야 한다. 상태관리 라이브러리로 React의 Context API, zustand, redux, jotai 등을 전체 레이아웃에 적용해주면 된다. 실무에서는 zustand가 좋겠지만, 간단하게 Context API로 로그인 시 데이터를 공유하도록 구현한다.

     

    app/_layout.tsx

    import AsyncStorage from '@react-native-async-storage/async-storage';
    import { Stack, router } from 'expo-router';
    import * as SecureStore from 'expo-secure-store';
    import { createContext, useState } from 'react';
    import { Alert } from 'react-native';
    
    interface User {
      id: string;
      name: string;
      description: string;
      profileImage: string;
    }
    
    interface AuthContextType {
      user: User | null;
      login: () => Promise<void>;
      logout: () => Promise<void>;
    }
    
    export const AuthContext = createContext<AuthContextType | null>(null);
    
    export default function RootLayout() {
      const [user, setUser] = useState<User | null>(null);
    
      const login = () => {
        console.log('login');
        return fetch('/login', {
          method: 'POST',
          body: JSON.stringify({
            username: 'user0',
            password: '1235', // '234'
          }),
        })
          .then((res) => {
            console.log('res', res, res.status);
            if (res.status >= 400) {
              return Alert.alert('Error', 'Invalid credentials');
            }
            return res.json();
          })
          .then((data) => {
            console.log('data', data);
            return Promise.all([
              SecureStore.setItem('accessToken', data.accessToken),
              SecureStore.setItem('refreshToken', data.refreshToken),
              AsyncStorage.setItem('user', JSON.stringify(data.user)),
            ]).then(() => {
              setUser(data.user);
              router.push('/(tabs)');
            });
          })
          .catch((error) => {
            console.error(error);
          });
      };
    
      const logout = async () => {
        setUser(null);
        await Promise.all([
          SecureStore.deleteItemAsync('accessToken'),
          SecureStore.deleteItemAsync('refreshToken'),
          AsyncStorage.removeItem('user'),
        ]);
      };
    
      return (
        <AuthContext value={{ user, login, logout }}>
          <Stack screenOptions={{ headerShown: false }}>
            <Stack.Screen name="(tabs)" />
            <Stack.Screen name="modal" options={{ presentation: 'modal' }} />
          </Stack>
        </AuthContext>
      );
    }

     

    이제 로그인 정보를 사용하는 곳에서는 AuthContext를 import 해서 사용해주면 된다.

    ...
    import { AuthContext } from '../_layout';
    
    export default function TabLayout() {
      const router = useRouter();
      const { user } = useContext(AuthContext) || {};
      const isLoggedIn = !!user?.id;
      ...

     

    로그아웃을 위한 사이드바

    로그아웃 메뉴는 home, search, activity, profile에서도 공유되어야 한다. 이런 기능은 컴포넌트로 빼주는 것이 좋은데, React Native는 파일 기반으로 라우팅이 되기 때문에 라우터에 걸리지 않도록 app 바깥에 components 폴더에서 작업을 진행한다. 코드의 폴더가 분산되는 것이 실다고 하면 app과 components를 src 폴더로 감싸주어도 된다. app 폴더가 Expo Router에서 인식하는 특수한 폴더인지만, src 폴더로 app 폴더로 감싸는 것은 허용해주고 있다.

    Sidebar 코드를 home, search, activity, profile 각각의 페이지에 적용해준다.

     

    SideMenu.tsx

     

    threads-clone/components/SideMenu.tsx at main · redcontroller/threads-clone

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

    github.com

     

    종료해도 유지되는 로그인

    추천하는 작업으로는 accessToken이 유효한지 검사를 해보는 것이다. 애플리케이션이 종료되더라도 유저 정보가 AsyncStorage에 남아 있어서 로그인에 문제가 없다고 생각될 수 있지만, 로그인한 지 너무 오래된 경우가 있을 수 있다. 예를들면 애플리케이션을 한 달만에 켰다면 AsyncStorage에는 데이터가 저장되어 남아 있어 로그인이 된 걸로 보이겠지만 실제로 한 달만에 로그인을 하면 accessToken/refreshToken이 모두 만료되어 있을 것이다. 그렇기 때문에 현재 가지고 있는 토큰을 한번 검사한 후에 만료되었다면 AsyncStorage에 유저정보를 없애고 로그인이 풀리도록 해야 한다.

      useEffect(() => {
        AsyncStorage.getItem('user').then((user) => {
          setUser(user ? JSON.parse(user) : null);
        });
        // TODO: 토큰 재발급
      }, []);

     

    6. Expo Router Navigation

    Home 경로에서 For you, Fallowing 같은 경우 클릭을 해서 탭을 넘어갈 수도 있지만 아이폰의 경우 사용자의 스와이프 제스쳐를 통해서 넘기는 경우가 많다.  트렌드마다 다르지만  탭 네비게이션은 주로 터치 방식으로 전환하며, 그 안에 상세 탭은 스왕프로 많이 넘기는 추세이다. Profile 경로 또한 내부에 Threads, Replies, Reposts 탭이 있고 이런 탭을 스와이프 제스쳐로 넘어가도록 구현을 해본다.

    해당 기능 구현을 위해서 Material Top Tabs를 설치한다. 그리고 npx expo 명령어가 아니다 보니, expo 라이브러리 버전이 꼬일 시에 node_modules 폴더를 제거하고 expo 명령어로 다시 전체 패키지를 설치하는 것이 좋다.

    npm install @react-navigation/material-top-tabs
    
    // expo 의존성이 꼬일 시 실행
    rm -rf node_modules
    npx expo install --fix

     

    일단 profile 페이지에서 username 하위로 Slot 대신 Material Top Tabs로 바꿔준다. 이때 내부 탭의 공통 부분인 프로필 부분까지 스와이프가 되는 문제가 발생한다. 이 문제는 공통인 부분을 _layout.tsx로 넘겨주면 해결된다.

    import { AuthContext } from '@/app/_layout';
    import SideMenu from '@/components/SideMenu';
    import { Ionicons } from '@expo/vector-icons';
    import {
      type MaterialTopTabNavigationEventMap,
      type MaterialTopTabNavigationOptions,
      createMaterialTopTabNavigator,
    } from '@react-navigation/material-top-tabs';
    import type {
      ParamListBase,
      TabNavigationState,
    } from '@react-navigation/native';
    import { withLayoutContext } from 'expo-router';
    import { useContext, useState } from 'react';
    import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
    import { useSafeAreaInsets } from 'react-native-safe-area-context';
    
    const { Navigator } = createMaterialTopTabNavigator();
    
    export const MaterialTopTabs = withLayoutContext<
      MaterialTopTabNavigationOptions,
      typeof Navigator,
      TabNavigationState<ParamListBase>,
      MaterialTopTabNavigationEventMap
    >(Navigator);
    
    export default function TabLayout() {
      const insets = useSafeAreaInsets();
      const [isSideMenuOpen, setIsSideMenuOpen] = useState(false);
      const { user } = useContext(AuthContext) || {};
      const isLoggedIn = !!user?.id;
    
      return (
        <View
          style={[
            styles.container,
            { paddingTop: insets.top, paddingBottom: insets.bottom },
          ]}
        >
          <View style={styles.header}>
            {isLoggedIn && (
              <Pressable
                style={styles.menuButton}
                onPress={() => {
                  setIsSideMenuOpen(true);
                }}
              >
                <Ionicons name="menu" size={24} color="black" />
              </Pressable>
            )}
            <SideMenu
              isVisible={isSideMenuOpen}
              onClose={() => setIsSideMenuOpen(false)}
            />
          </View>
          <View style={styles.profile}>
            <View style={styles.profileHeader}>
              <Image
                source={{ uri: user?.profileImageUrl }}
                style={styles.profileAvatar}
                resizeMode="cover"
              />
              <View style={styles.profileInfo}>
                <Text style={styles.userId}>{user?.id}</Text>
                <Text style={styles.userName}>{user?.name}</Text>
                <Text style={styles.userDescription}>{user?.description}</Text>
              </View>
            </View>
          </View>
          <MaterialTopTabs>
            <MaterialTopTabs.Screen name="index" options={{ title: 'Threads' }} />
            <MaterialTopTabs.Screen name="replies" options={{ title: 'Replies' }} />
            <MaterialTopTabs.Screen name="reposts" options={{ title: 'Reposts' }} />
          </MaterialTopTabs>
        </View>
      );
    }
    
    const styles = StyleSheet.create({
      container: {
        flex: 1,
      },
      header: {
        flexDirection: 'row',
        justifyContent: 'space-between',
        alignItems: 'center',
        paddingHorizontal: 16,
      },
      menuButton: {
        padding: 8,
      },
      profile: {
        padding: 16,
      },
      profileHeader: {
        flexDirection: 'row',
        alignItems: 'center',
        gap: 16,
      },
      profileAvatar: {
        width: 48,
        height: 48,
        borderRadius: 24,
      },
      profileInfo: {
        flex: 1,
      },
      userId: {
        fontSize: 16,
        fontWeight: 'bold',
      },
      userName: {
        fontSize: 14,
        color: '#666',
      },
      userDescription: {
        fontSize: 12,
        color: '#999',
      },
    });

     

    Material Top Tabs으로 개선된 profile 탭

     

    똑같은 방식으로 home 탭에도 Swipe 형태의 Material Top Tabs를 적용해준다.

    Material Top Tabs를 적용한 home 탭

     

    home 탭의 코드는 style 옵션을 추가한 MateriaTopTabs 컴포넌트만 확인해보자. 로그인을 한 경우에 탭이 보이도록 하고, 로그아웃 상태에서는 Slot 컴포넌트로 게시물의 구분없이 보이도록 했다. MaterialTopTabs의 옵션값은 공식문서를 참고하되, 기본값이 false인 lazy 속성은 꼭 true 값으로 해두도록 하자. 기본값이 false이기 떄문에 'For you' 탭과 'Following' 탭 페이지 모두 렌더링이 된다. 하지만 실제로 스와이프 하지 않으면 나머지 페이지는 볼 일이 없다. 미리 로딩을 하면 성능상에 낭비이기 때문에 사용자가 스와프로 넘겼을 때 렌더링을 수행하도록 lazy 속성을 true로 활성화 해두도록 하자.

          {isLoggedIn ? (
            <MaterialTopTabs
              screenOptions={{
                lazy: true,        // 꼭 활성화 하자!
                tabBarStyle: {
                  backgroundColor: 'white',
                  shadowColor: 'transparent',
                  position: 'relative',
                },
                tabBarPressColor: 'transparent',
                tabBarActiveTintColor: '#555',
                tabBarIndicatorStyle: {
                  backgroundColor: 'black',
                  height: 1,
                },
                tabBarIndicatorContainerStyle: {
                  backgroundColor: '#aaa',
                  position: 'absolute',
                  top: 49,
                  height: 1,
                },
              }}
            >
              <MaterialTopTabs.Screen name="index" options={{ title: 'For you' }} />
              <MaterialTopTabs.Screen
                name="following"
                options={{ title: 'Following' }}
              />
            </MaterialTopTabs>
          ) : (
            <Slot />
          )}

     

    7. 딥링킹, 유니버셜링킹으로 내 앱으로 연결하기

    딥링크 변경

    웹링크(Web Link)가 사용자를 특정 웹사이트로 이동시키듯이, 딥링크 (Deep Link)란 사용자를 특정 앱으로 이동시켜서 원하는 화면을 보여주거나, 사용자의 액션을 유도하는 방식이다. Expo 라우터는 모든 탭들 그리고 모든 주소들이 전부 딥링킹이 동작한다. 딥링킹의 주소를 바꾸는 방법은 app.json 파일에서 expo 하위에 scheme을 바꿔주면 '[scheme]://' 형태로 시작하는 딥링크가 된다. threads는 앱 내에서 설치 되어 있을 수 있으니 threadc로 해줬다.

     

    app.json

    {
      "expo": {
        ...
        "scheme": "threadc",     // threadc://

     

    딥링크 테스트: uri-scheme

    실제 앱을 배포하고 나서는 딥링크가 잘 작동해야 할 것이다. 이를 위해 딥링크가 제대로 동작하는지 테스트 하는 방법이 있다. uri-scheme으로 테스트 할 때는 현재 플랫폼이 android 또는 ios인지 명시해주어야 한다. 그리고 실제 앱의 경우와 달리 Expo의 경우 앞의 주소에 하이픈 두 개(--)까지 붙여준 뒤에 경로를 써주어야 한다.

    # Expo Go 환경에서 딥링크 테스트
    npx uri-scheme open exp://127.0.0.1:8081/--/activity --android
    npx uri-scheme open exp://127.0.0.1:8081/--/activity/reposts --android
    npx uri-scheme open exp://127.0.0.1:8081/--/following --android
    npx uri-scheme open exp://127.0.0.1:8081/--/user0 --android
    npx uri-scheme open exp://127.0.0.1:8081/--/user0/replies --android
    
    # 실제 배포 앱에서 scheme 딥링크 테스트
    npx uri-scheme open threadc://activity --android
    
    # app.json에 'android.package' or 'ios.bundleIdentifier' 가 정의된 경우
    npx uri-scheme open com.example.app://somepath/details --android

     

    마찬가지로 자신의 앱에서 다른 앱을 호출할 때도 scheme을 사용해서 호출하면 된다. 예를들면 카카오톡의 경우에 'kakao://' 같은 형태일 것이다. 만약 scheme이 없다면 android의 경우 'com.meta.threads://' 형태로 패키지 이름이 앞에 오고, 'bundleIdentifier://" 형태이며 bundleIdentifier가 지정한 값이 들어간다.

     

    혹시, 아래와 같은 에러가 뜨면서 Expo 상에서 uri-scheme 테스트가 안된다면 현재 애뮬레이터로 실행하고 있는 모드가 Expo Go인지 development build인지 확인해보자. Expo Go 상태로 바꾼 뒤 (Press S) 테스트 명령어를 실행해야 딥링크 잘 동작한다.

    Error: Activity not started, unable to resolve Intent { act=android.intent.action.VIEW dat=exp://127.0.0.1:8081/... flg=0x10000000 xflg=0x4 }

    development build에서 Expo Go로 변경 (Press s)

    유니버셜 링킹

    모바일에서 앱이 설치되어 있다면 앱이 열리고, 설치되어 있지 않으면 설치 화면으로 넘어가는 경우를 접해본 적 있을 것이다. 이런 기능을 구현한다고 하면 보통 앱이 설치되어 있지 않을 때는 앱 설치 안내 웹사이트가 뜨거나 앱 스토어로 이동해주는 기능일 것이다. 그런데 딥링킹의 단점은 앱이 설치되어 있지 않으면 동작하지 않는다. 아에 반응이 없다. 이럴 때 사용하는 것이 유니버셜 링킹이다.

    앱에서 유니버셜 링킹을 사용하기 위해서는 Android 앱링크 설정 또는 iOS 유니버셜 링크 설정이 따로 필요하다. 해당 설정을 위해서는 도메인 주소도 필요하다.

     

    Linking.openURL

    또 다른 방법으로 프로그래밍으로 링크를 열 수도 있다. Expo의 Link 컴포넌트와 Linking.openURL을 사용하는 방식이다. expo-linking을 설치하고, 이벤트 핸들러 함수에 아래와 같이 Linking.openURL를 사용해볼 수도 있다.

    npx expo install expo-linking
    import { Button, View, StyleSheet } from 'react-native';
    import * as Linking from 'expo-linking';
    
    export default function Home() {
      return (
        <View style={styles.container}>
          <Button title="Open a URL" onPress={() => Linking.openURL('https://expo.dev/')} />
        </View>
      );
    }
    
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        alignItems: 'center',
        justifyContent: 'center',
      },
    });

     

    일반적인 URL schemes

    • https/http: Open Web browser app. (https://expo.dev)
    • mailto: Open mail app. (mailto: support@expo.dev)
    • tel: Open phone app. (tel: +123456789)
    • sms: Open SMS app. (sms: +123456789)

    8. 다크모드로 변경

    모바일에서 많이 사용하는 다크모드를 애뮬레이터에서도 적용할 수 있다. 애뮬레이터 설정에서 적용할 수도 있지만, 터미널 명령어를 통해 다크모드로 변경할 수도 있다. adb shell 명령어는 안드로이드 애뮬레이터와 통신하는 명령어이다. 이와 관련된 명령어를 잘 알아두면 애뮬레이터 설정을 타고 들어가는 번거로움 없이 자주 사용하는 설정을 빠르게 (비)활성화 할 수 있다.

    // 다크모드 활성화
    adb shell "cmd uimode night yes"
    
    // 다크모드 해제
    adb shell "cmd uimode night no"

     

    아래 app.jsond을 통해서 미리 애플리케이션 설정해 둘 수 있다. expo 설정의 userInterfaceStyle을 automatic으로 하면 사용자의 기본 기기 설정을 따라가도록 하며, light 또는 dark를 넣어주면 사용자 기기 설정과 관계없이 고정으로 애플리케이션을 사용할 때 라이트모드 또는 다크모드가 적용된다.

    {
      "expo": {
        ..
        "userInterfaceStyle": "automatic",      // light or dark
        ...

     

    다크모드 확인

    그래서 color scheme에 적용받는 스타일과 적용받지 않는 스타일을 분리해주어야 최적화할 때 도움이 된다.

     

    /(home)/_layout.tsx

            {!isLoggedIn && (
              <TouchableOpacity
                style={[
                  styles.loginButton,
                  colorScheme === 'dark'
                    ? styles.loginButtonDark
                    : styles.loginButtonLight,
                ]}
                onPress={() => {
                  console.log('loginButton onPress');
                  router.replace('/login');
                }}
              >
                <Text
                  style={
                    colorScheme === 'dark'
                      ? styles.loginButtonTextDark
                      : styles.loginButtonTextLight
                  }
                >
                  로그인
                </Text>
              </TouchableOpacity>
            )}
            
            ...
    
    const styles = StyleSheet.create({
      ...
      loginButton: {
        padding: 8,
        borderRadius: 4,
        position: 'absolute',
        right: 16,
      },
      loginButtonDark: {
        backgroundColor: '#fff',
      },
      loginButtonLight: {
        backgroundColor: '#101010',
      },
      loginButtonTextLight: {
        color: '#fff',
      },
      loginButtonTextDark: {
        color: '#101010',
      },
    });

     

    8. 공유하기 기능

    프로필과 게시글 페이지에는 링크를 공유하는 기능들이 있다. 이 기능은 추가적인 라이브러리 설치 없이 React Native Core에 Share 컴포넌트를 사용하여 링크나 파일 공유 기능을 구현할 수 있다.

     

    프로필 링크 공유

    아래 코드와 같이 message 속성에 유니버셜 링크를 작성해주는 것만으로도 간단하게 링크 공유 기능을 구현할 수 있다.

     

    app/(tabs)/[username]/_layout.tsx

      import { Share } from 'react-native';
      
      const handleShareProfile = async () => {
        console.log('share profile');
        try {
          await Share.share({
            message: `thread://@${username}`,
          });
        } catch (error) {
          console.error(error);
        }
      };

     

    게시글 링크 공유

    똑같이 Share를 사용해서 게시글 공유하기 버튼에 사용하는 이벤트 핸들러를 아래와 같이 구현할 수 있다. React Native 공식문서에 따르면 적어도 message와 url 중 하나는 필수이며, iOS/Android 모든 플랫폼에서 운영되어야 한다면 message, url, title 속성을 모두 넣어주면 좋다.

     

    components/Post.tsx

      const handleShare = async (username: string, postId: string) => {
        const shareUrl = `thread://@${username}/post/${postId}`;
        try {
          await Share.share({
            message: shareUrl,
            url: shareUrl, // iOS에서는 url도 함께 전달하는 것이 좋습니다.
          });
        } catch (error) {
          console.error('Error sharing post:', error);
          // 사용자에게 오류 메시지를 표시할 수도 있습니다.
        }
      };

     

    파일 공유

    Expo 앱 → 다른 앱으로 파일을 공유하는 기능이 필요하다면 Expo Sharing 라이브러리를 사용하면 된다. 

    npx expo install expo-sharing

     

    사용방법은 Sharing.isAvailableAsync() 가 true라면, Sharing.shareAsync(url, options)를 호출하면 된다. options에는 android용과 iOS용이 따로 존재한다.

    • url: 로컬 파일 URL
    • options
      • anchor: (iOS) iPad용 앵커 포인트 설정
      • dialogTitle: (iOS/Android) 대화창 타이틀
      • mimeType: (Android) 선택할 파일의 MIME 타입 (ex. *.png, *.jpg)
      • UTI: (iOS) Uniform Type Identifier. 선택할 파일의 타입 (mimeType과 유사)
    import * as Sharing from 'expo-sharing';

     

    반대로, 다른 앱 → Expo 앱으로 파일을 공유 받는 경우도 있다. 하지만 Expo에서는 기본적으로 지원하지 않는 기능이다. 이때는 Expo Share Intent과 같은 외부 라이브러리를 설치해야 된다. 이런 외부 라이브러리의 경우 prebuild를 해야 하는데, prebuild 수행하면 Expo Go를 벗어나게 된다. 즉, Native 단을 건드는 폴더가 생성되고 다시 Expo Go로 되돌아올 수 없기 때문에 주의해야 한다. 그래서 외부 라이브러리를 쓸 때는 SDK 버전 호환과 prebuild를 해야 하는지 여부가 중요하다. 하지만 결국에는 push 알람 같은 기능 때문이라도 prebuild로 넘어 가야 한다.

     

    9. StatusBar

    React Native Core에도 StatusBar가 존재하지만 Android와 iOS가 서로 다른 동작을 가지게 되는 현상이 발생하여 일일이 맞춰줘야 하는 불편함이 존재한다. 때문에 그런 불편함을 해결하여 Android와 iOS에서 똑같은 동작을 볼 수 있도록 조절된 Expo의 StatusBar를 사용하는 것이 낫다. StatusBar는 아래의 옵션값 뿐만 아니라 setStatusBarHidden, setStatusBarStyle, setStatusBarBackgroundColor 메서드를 통해서도 설정할 수도 있다.

    • style
      • auto: appearnace에 설정된 모드에 맞춰서 적절히 설정된다.
      • dark: appearance가 라이트모드일 때 사용된다. 상태창이 검은색으로 바뀐다.
      • light: appearance가 다크모드일 때 사용된다. 상태창이 흰색으로 바뀐다.
    • (Android) backgroundColor: 상대창 색상을 바꿀 수 있지만, 설정이 안 먹히는 경우가 있다.
    • translucent
      • true (default): 상태창 겹쳐져서라도 화면을 그려지게 할 수 있도록 설정
      • false: 상태창 공간을 확보하여 겹쳐지지 않도록 화면을 설정
    • animated: 애니메이션처럼 부드럽게 바뀔 것인지
    • hidden: 상태창을 숨길지 여부 (true/false). 이 설정을 활용해 상태창을 지우고 컨텐츠를 꽉 차게 보일 수도 있음.
    export default function RootLayout() {
      return (
        <AnimatedAppLoader image={require('../assets/images/react-logo.png')}>
          <StatusBar style="auto" animated />
          <Stack screenOptions={{ headerShown: false }}>
            <Stack.Screen name="(tabs)" />
            <Stack.Screen name="modal" options={{ presentation: 'modal' }} />
          </Stack>
        </AnimatedAppLoader>
      );
    }

     

    10. 스플래시 스크린 커스터마이징: Asset, Constants

    스플래시 스크린 이미지 설정

    현재 스플래시 화면에서는 assets 폴더 내에 있는 기본 Expo의 splash-icon.png를 사용하고 있다. 해당 설정은 app.json 내부 plugins 부분에 적용을 해둔 상태이다.

     

    app.json

        "plugins": [
          "expo-router",
          [
            "expo-splash-screen",
            {
              "image": "./assets/images/splash-icon.png",
              "imageWidth": 200,
              "resizeMode": "contain",
              "backgroundColor": "#ffffff"
            }
          ],
          ...

     

    주변의 앱들을 살펴보면 정적인 이미지가 아닌, 움직이거나 데이터를 다운 받는 애니메이션이 들어가 있는 스플래시 스크린을 많이 볼 수 있다. 지금 클론 코딩에서 스플래시 스크린이 필요한 이유는, 앱을 켰을 때 로그인을 한 상황이어도 잠깐 동안 로그아웃 상태의 화면이 보였다가 로그인 후에 화면으로 전환되는 문제가 있다. 이 문제는 앱을 켰을 때 useEffect가 실행되면서 AsyncStorage에서 유저 정보가 있으면 setUser()를 실행하면서 딜레이가 발생하여 보이는 현상이다. 스플래시 스크린은 이러한 초기 로딩에서 딜레이 되는 현상들을 감추는 역할도 한다.

     

    스플래시 스크린의 주 역할

    • 브랜드 아이덴티티(정체성)을 보여주는 역할
    • 초기 데이터 로딩 및 세팅 중에 보여주는 화면

     

    app/_layout.tsx

      useEffect(() => {
        AsyncStorage.getItem('user').then((user) => {
          setUser(user ? JSON.parse(user) : null);
        });
      }, []); // 초기 로딩시에 딜레이 발생

     

     

    스플래시 스크린 커스터마이징

    직접 스플래시 스크린을 구현해본다. 우선 스플래시 이미지를 불러오고, 로드가 완료되었는지 판단하면서도 Context API는 최상위로 끌어오는 역할을 수행하는 AnimatedAppLoader 컴포넌트를 만든다. 초기 렌더링이 완료되면 이미지를 불러오기 위해 Expo의 Asset을 사용한다. 초기에는 아무것도 보여줬다가 (return null), 이미지가 불러와지면 SplashReady 상태를 true로 업데이트하여 스플래시 화면을 보여주도록 한다.

     

    Asset

    • Asset.fromURI('https://naver.com/favicon.png').downloadAsync() : URL의 파일을 다운로드에 사용
    • Asset.loadAsync(image): 로컬 이미지를 불러올 때 사용
      useEffect(() => {
        async function prepare() {
          await Asset.loadAsync(image); // Local image loading
          setSplashReady(true);
        }
        prepare();
      }, [image]);
      
      if (!isSplashReady) {
        return null;
      }

     

    이미지를 가져왔으니, 애니메이션을 적용해주는 컴포넌트 AnimatedSplashScreen를 작성해본다. 스플래시 스크린은 전체화면을 사용하기 때문에 style 속성을 적절히 사용해서 전체화면 설정을 적용해줄 수 있다. StyleSheet의 AbsoluteFillStyle은 { position: 'absolute', left:0, right:0 top:0, bottom:0 }의 줄임표현이다. 

     

    전체화면 style

    • style={{ StyleSheet.absoluteFillObject }}
    • style={{ ... StyleSheet.absoluteFillObject, backgroundColor: 'red' }}
    • style={{ flex:1 }}

    AnimatedSplashScreen 컴포넌트는 두 가지 상태를 가진다. 먼저 isAppReady를 통해서 상태가 true라면 children인 라우터를 보여주지만, false라면 스플래시 화면을 보여준다. 그리고 스플래시 화면은 스플래시 애니메이션이 끝나기 전까지 보여주다가 isSplashAnimatedComplete 상태를 true로 업데이트한다.

    function AnimatedSplashScreen({ children, image }: {
      children: React.ReactNode;
      image: number;
    }) {
      const [isAppReady, setIsAppReady] = useState(false);
      const [isSplashAnimationComplete, setAnimationComplete] = useState(false);
     
      ...
    
      return (
        <View style={{ flex: 1 }}>
          {isAppReady && children}
          {!isSplashAnimationComplete && (
            ...
          )}
        </View>
      );
    }

     

    애니메이션의 경우 변하는 값에 집중하자. 1을 0으로 투명도와 React 로고를 줄여줄 것이다.

      ...
      const animation = useRef(new Animated.Value(1)).current;
    
      useEffect(() => {
        if (isAppReady) {
          Animated.timing(animation, {
            toValue: 0,
            duration: 2000,
            useNativeDriver: true,
          }).start(() => setAnimationComplete(true));
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [isAppReady]);
    ...

     

    이제 JSX 엘레먼트에 애미메이션을 적용해본다. 이때 style 속성을 app.json에서 사용했던 splash 속성들을 가져와서 사용할 것이다. plugins 속성에서 가져오려면 번거로워지니, splash 속성을 추가해서 가져오도록 한다. app.json의 설정값을 가져올 때에는 Expo Constants를 사용한다. 그리고 Constants.expoConfig?.splash?.~ 형태로 사용하면 된다.

     

    app.json

        ...
        "splash": {
          "image": "./assets/images/splash-icon.png",
          "imageWidth": 200,
          "resizeMode": "contain",
          "backgroundColor": "#ffffff"
        },
        "plugins": [
          ...

     

    app/_layout.tsx

     

    import Constants from 'expo-constants';
    
    ...
    
    function AnimatedSplashScreen({
     ...
    
      return (
        <View style={{ flex: 1 }}>
          {isAppReady && children}
          {!isSplashAnimationComplete && (
            <Animated.View
              ...
              <Animated.Image
                source={image}
                style={{
                  resizeMode: Constants.expoConfig?.splash?.resizeMode || 'contain',
                  width: Constants.expoConfig?.splash?.imageWidth || 200,
                  transform: [{ scale: animation }, { rotate: rotateValue }],
                }}
                onLoadEnd={onImageLoaded}
                fadeDuration={0}
              />
            </Animated.View>
          )}
        </View>
      );
    }

     

    스플래시 화면 자동 숨김 기능 막기

    추가적으로 Expo에 app.json에 설정된 기본 스플래시 이미지는 언제 사라질까? Expo가 처음 _layout.tsx가 실행될 때 스플래시 이미지를 바로 없앤다. 그래서 이때 Expo의 기본 스플래시 화면과 커스터마이징한 애니메이션 스플래시 화면 사이에 시간차가 발생할 수 있다. 그렇게되면 React 로고가 없어졌다가 다시 뜨는 상황이 나타날 수 있다. 그래서 Expo가 기본 스플래시 화면을 자동으로 없애는 행위를 코드 상에서 막아주도록 한다. 그리고 코드에서 기능을 막아 안 사라지는 기본 스플래시 화면은 계속 화면상에 남아 있기 때문에, 코드 상에서 수동으로 없애주는 코드 또한 추가해줘야 한다. 이제 기본 스플래스 화면이 사라지면, 애니메이션을 적용한 커스터마이징 스플래시 화면에 보여지게 된다. 

    import * as SplashScreen from "expo-splash-screen";
    
    // Instruct SplashScreen not to hide yet, we want to do this manually
    SplashScreen.preventAutoHideAsync().catch(() => {
      // reloading the app might trigger same race conditions, ignore them
    });
    
    ...
    
      const onImageLoaded = async () => {
        try {
          ... // 사용자 데이터 준비
          await SplashScreen.hideAsync(); // 수동으로 SplashScreen 숨기기
        ...

     

    Expo Go의 단점

    앱을 다시 실행해보면 기본 스플래시 화면이 안 사라지는 것을 확인할 수 있다. 그런데 사실 그건 기본 스플래시 스크린이 아니라 앱 아이콘이다. 이 점이 바로 Expo Go의 단점이다. Expo Go를 실행하면 무조건 앱 아이콘이 먼저 뜨고 다음에 스플래시 화면이 렌더링이 된다.

    실제 앱이 아니기 때문에 앱 로고가 먼저 뜨는 현상

    왜냐하면 Expo Go는 실제 앱이 아니기 때문에 그렇다. 나중에 배포할 때는 앱 아이콘 없이 바로 스플래시 스크린이 뜨도록 빌드를 수행할 예정이다. 스플레시 화면이 제대로 동작하는 빌드 모드는 preview와 production 빌드이다. 이후 Preview 빌드를 수행하면 앱 아이콘이 뜨는 절차 없이 설계된 스플래시 스크린을 커스터마이징 절차대로 React 로고가 줄어드는 것이 먼저 렌더링 될 것이다.

     

    React Native 빌드 종류

    • development
    • preview
    • production
    // eas.json
    {
      "cli": {
        "version": ">= 16.12.0",
        "appVersionSource": "remote"
      },
      "build": {
        "development": {
          "developmentClient": true,
          "distribution": "internal"
        },
        "preview": {
          "distribution": "internal"
        },
        "production": {
          "autoIncrement": true
        }
      },
      "submit": {
        "production": {}
      }
    }

     

    회전하는 애미메이션 효과: animation.interpolate()

    이미지의 크기가 줄어드는 애니메이션 효과만으로는 너무 심심한 느낌이라 회원하는 효과를 추가한다. 주의할 점은 animation이 1에서 0으로 바뀌기는 하지만 숫자(number)는 아니다.  그렇기 때문에 transform의 rotation에 animation 값 직접 사용할  없고 animation.interpolate() 메서드를 사용해주어야 한다. interpolate 메서드는 inputRange를 outputRange로 변환해준다. 이때 inputRange는 숫자가 커지는 순서로 작성해주어야 한다.

    ...
      const rotateValue = animation.interpolate({
        inputRange: [0, 1],
        outputRange: ['0deg', '360deg'],
      });
    
      return (
          <View style={{ flex: 1 }}>
          ...
            <Animated.View
              ...
              <Animated.Image
                source={image}
                style={{
                  resizeMode: Constants.expoConfig?.splash?.resizeMode || 'contain',
                  width: Constants.expoConfig?.splash?.imageWidth || 200,
                  //  transform: [
                  //    { scale: animation },
                  //    { rotate: 360 * Number(animation) + 'deg' },  // Error!
                  //  ],
                  transform: [{ scale: animation }, { rotate: rotateValue }],
                }}
                onLoadEnd={onImageLoaded}
                fadeDuration={0}
              />
            </Animated.View>
          )}
        </View>
      );
    }
    ...

     

    전체적인 애니메이션 스플래시 스크린의 동작 순서

    1. 기본 스플래시 스크린이 렌더링 되어 있는 상태
    2. RootLayout이 렌더링되며, AnimatedAppLoader를 렌더링
    3. AnimatedAppLoader
      • Asset.loadAsync로 이미지를 미리 로드
      • 이미지 로드 후, isSplashReady === True가 되면 AnimatedSplashScreen을 렌더링
    4. AnimatedSplashScreen
      • Animated.View, Animated.Image가 렌더링되면서 onImageLoaded가 호출
      • onImageLoaded에서 사용자 데이터들을 준비하고 앱의 기본 스플래시 스크린을 제거
      • isAppReady === True가 되면, Animated.timinig이 수행되며 애니메이션이 실행
      • Animated.View 아래에 children(라우터)가 표시
      • 애니메이션 완료 후 isSplashAnimationComplete가 true가 되며 Animated.View가 사라짐
    import { Asset } from 'expo-asset';
    import Constants from 'expo-constants';
    
      ...
      
    function AnimatedAppLoader({ children, image }: {
      children: React.ReactNode;
      image: number;
    }) {
      const [user, setUser] = useState<User | null>(null);
      const [isSplashReady, setSplashReady] = useState(false);
    
      useEffect(() => {
        async function prepare() {
          await Asset.loadAsync(image); // Local image loading
          setSplashReady(true);
        }
        prepare();
      }, [image]);
    
      ...
    
      useEffect(() => {
        AsyncStorage.getItem('user').then((user) => {
          setUser(user ? JSON.parse(user) : null);
        });
      }, []);
    
      if (!isSplashReady) {
        return null;
      }
    
      return (
        <AuthContext value={{ user, login, logout, updateUser }}>
          <AnimatedSplashScreen image={image}>{children}</AnimatedSplashScreen>
        </AuthContext>
      );
    }
    
    function AnimatedSplashScreen({
      children,
      image,
    }: {
      children: React.ReactNode;
      image: number;
    }) {
      const [isAppReady, setIsAppReady] = useState(false);
      const [isSplashAnimationComplete, setAnimationComplete] = useState(false);
      const animation = useRef(new Animated.Value(1)).current;
      const { updateUser } = useContext(AuthContext);
    
      useEffect(() => {
        if (isAppReady) {
          Animated.timing(animation, {
            toValue: 0,
            duration: 2000,
            useNativeDriver: true,
          }).start(() => setAnimationComplete(true));
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [isAppReady]);
    
      const onImageLoaded = async () => {
        try {
          // 데이터 준비
          await Promise.all([
            AsyncStorage.getItem('user').then((user) => {
              updateUser?.(user ? JSON.parse(user) : null);
            }),
            // TODO: validating accessToken
          ]);
          await SplashScreen.hideAsync(); // 수동으로 SplashScreen 숨기기
        } catch (error) {
          console.error(error);
        } finally {
          setIsAppReady(true);
        }
      };
    
      const rotateValue = animation.interpolate({
        inputRange: [0, 1],
        outputRange: ['0deg', '360deg'],
      });
    
      return (
        <View style={{ flex: 1 }}>
          {isAppReady && children}
          {!isSplashAnimationComplete && (
            <Animated.View
              pointerEvents="none"
              style={{
                ...StyleSheet.absoluteFillObject,
                flex: 1,
                justifyContent: 'center',
                alignItems: 'center',
                backgroundColor:
                  Constants.expoConfig?.splash?.backgroundColor || '#ffffff',
                opacity: animation,
              }}
            >
              <Animated.Image
                source={image}
                style={{
                  resizeMode: Constants.expoConfig?.splash?.resizeMode || 'contain',
                  width: Constants.expoConfig?.splash?.imageWidth || 200,
                  transform: [{ scale: animation }, { rotate: rotateValue }],
                }}
                onLoadEnd={onImageLoaded}
                fadeDuration={0}
              />
            </Animated.View>
          )}
        </View>
      );
    }
    
    export default function RootLayout() {
      return (
        <AnimatedAppLoader image={require('../assets/images/react-logo.png')}>
          <StatusBar style="auto" animated />
          <Stack screenOptions={{ headerShown: false }}>
            <Stack.Screen name="(tabs)" />
            <Stack.Screen name="modal" options={{ presentation: 'modal' }} />
          </Stack>
        </AnimatedAppLoader>
      );
    }

     

    인앱 브라우저

    링크가 있는 게시물의 경우 링크를 누르면 어떻게 될까? Link 컴포넌트 대신 API로 링크를 여는 방식인 Linking.openURL를 사용했다면 링크를 설정된 웹 브라우저로 열 수 있다. 하지만 웬만한 앱 서비스들을 보면 별도의 웹 브라우저에서 열리는 것이 아닌, 앱 내에서 인앱 브라우저가 떠서 링크가 연결된다. 유저 입장에서는 웹 브라우저가 열려도 큰 상관은 없을지 모르지만, 앱 개발사 입장에서는 앱체류 시간을 늘릴 수 있기 때문에 인앱 브라우저를 사용하길 선호한다. 인앱 브라우저의 경우 브라우저를 끄면 앱으로 돌아오지만, 외부 웹 브라우저의 경우 종료 시에 홈 화면으로 이동하게 된다. 때문에 요즘 앱들은 대부분 링크를 누를 때 인앱 브라우저로 링크를 열도록 UX를 설계하고 있다.

     Expo에서도 인앱 브라우저를 지원한다. openBrowserAsync 메서드를 사용하면 링크를 인앱 브라우저로 열수 있다. 인앱 브라우저와 외부 웹 브라우저의 화면상에서의 차이는 상단의 메뉴가 다르다. 엑스(X) 버튼은 인앱 브라우저 끄기이며 아래쪽 화살표(∨) 버튼은 인앱 브라우저 최소화이다.

    import * as WebBrowser from 'expo-web-browser';
    
    ...
            {!item.images?.length && item.link && (
              <Pressable onPress={() => WebBrowser.openBrowserAsync(item.link!)}>
                <Image
                  source={{ uri: item.linkThumbnail }}
                  style={styles.postLink}
                  resizeMode="cover"
                />
              </Pressable>
            )}
    ...

     

    Android 기기의 경우 실험적인 기능으로 제공되는 기능이 있다. 앱을 백그라운드로 보내도 인앱 브라우저 상태가 유지되는 기능인데, app.json에 아래 plugin 설정을 추가하면 된다. 이 밖에도 인앱 브라우저 종료 기능이나, Auth 인증을 할 때 웹 브라우저를 띄워서 외부 소셜미디어에서 로그인을 하는데 인앱 브라우저를 사용할 수 있다.

    {
      "expo": {
        "plugins": [
          [
            "expo-web-browser",
            {
              "experimentalLauncherActivity": true
            }
          ]
        ]
      }
    }

     

    WebBroswer.openAuthSessionAsync(url, redirectUrl, options)

    • url: Auth 인증할 소셜 미디어 주소
    • redirectUrl: 앱에서 콜백을 받아 처리할 주소
    • options: (선택사항) Native AuthSession 구현제가 존재하는 경우 무시됨. (default: { })

    인앱 브라우저 vs 웹뷰

    웹뷰는 react-native-webview 를 설치해서 사용한다. WebView는 컴포넌트이고, 웹 브라우저는 버튼을 클릭하거나 할 때 띄우는 API이라는 차이점이 있다. 웹뷰는 컴포넌트이자 화면이라고 보면 된다. 다시 말해, 웹뷰는 링크로 전달되는 웹 브라우저의 화면을 앱 자체에 컴포넌트로 웹사이트를 내장해 렌더링을 하는 것이다. 일반 사용자의 입장에서는 앱인지 웹인지 구별이 안 간다. 토스에서는 알게 모르게 웹뷰를 정말 많이 사용하고 있다. 즉, 사용자는 토스 앱을 켰다고 생각하지만 내부적으로 토스 웹이 돌아가고 있는 경우가 정말 많다.

    재미있는 부분은 웹뷰와 네이티브 앱을 개발해본 사람은 페이지를 전환할 때 미세한 차이를 느낄 수 있어서 구분하곤 하는데, 웹뷰를 진짜 잘한다고 하는 개발자들은 웹뷰를 네이티브 앱과 거의 차이 없게끔 개발한다.

    하이브리드 관점에서 접근하면 하단의 탭 네비게이션은 Expo 컴포넌트를 사용하고, 실제 콘텐츠 부분은 웹뷰로 대체해서 사용할 수 있다. 웹뷰의 이점은 네이티브 앱의 경우 업데이트가 있을 때 매번 앱 스토어나 플레이 스토어 심사를 받야 하지만, 웹뷰는 앱이 웹화면 그대로 쓰기 때문에 심사를 받지 않고도 웹사이트를 업데이트할 수 있다. 그래서 많은 실제 앱 서비스들이 웹뷰를 많이 사용하고 있다. 특히 플랫폼으로 갈수록 더 그런 경향이 있다. 회사에 당장 고쳐야 하는 긴급한 일이 발생했을 때, 구글이나 애플의 스토어 승인을 기다리고 있다는 것 자체가 내 목숨줄을 남에게 맡겨놓고 장사하는 것이나 다름없기 때문이다.

     웹뷰에 대해서 좀 더 알아보려면 Expo Docs 보다는, 더 자세하게 나와 있는 React Native WebView 공식 문서를 참고하는 것이 낫다. 추천하는 콘텐츠로는 웹뷰와 네이티브 앱이 어떻게 서로 소통하는지 위주로 보면 좋다. 예를들어 웹뷰 내에서 URL 주소가 바뀌었다면 Expo 앱에서 이벤트 리스너로 받을 수 있고, 파일을 다운로드 하거나 웹뷰 안에서 카메라를 사용했을 때 앱단에서 데이터를 받을 수 있다. 웹서비스를 하는 입장에서 앱을 출시한다면, 가장 첫 번째로 고려할 것은 React Native 웹뷰를 사용해서 웹사이트를 그대로 앱으로 통째로 옮겨서 빠르게 배포하는 것이다. 이때는 웹사이트를 웹뷰로 렌더링 하고, Native 단에서는 그 위에 내비게이션 바와 앱에서 회원가입/로그인과 같은 인증 기능만을 수행한다. 웹사이트와 Natvie 간에 소통할 때는 injectedJavaScript와 postMessage를 사용한다.

    npx expo install react-native-webview

    .

    .

    .

    .

    .

    .

    .

    .

    .

    관련 코드들

     

    threads-clone/app at main · redcontroller/threads-clone

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

    github.com

     

Designed by Tistory.