[프로젝트] DrawerMenu(서랍 메뉴) 만들기

Jade·2023년 10월 8일
2

프로젝트

목록 보기
28/28
post-thumbnail

코노플리 프로젝트를 1차로 구글 플레이스토어에 업로드했고,
이후로 미뤄뒀던 나머지 작업들(ex: 금영노래 DB 추가 등)을 진행하고 있는 중이다.

현재 탭은 인기차트 / 주변(지도) / 검색 / 플레이리스트 / 설정 이렇게 다섯가지로 구성되어 있는데
업데이트 시 '신곡' 탭을 넣자는 이야기가 나왔다.

확실히 노래방에 자주 가는 나도 신곡이 새롭게 나왔는지 확인하는 경우가 있어서 필요한 탭이라고 생각했다.
문제가 있다면 이미 탭이 다섯 메뉴로 꽉 차있다는 점이었다.
고민해보다 설정 페이지를 아예 Drawer 메뉴로 빼기로 했다.

🧳 Drawer

(아직 디자인은 좀 더 다듬어야 함... 디자이너가 없어서 울팀 도사님이 고생이 많으시다... 🥲)

서랍 메뉴라는 건 스마트폰을 사용하는 사람들이라면 이미 익숙할 UI인데,
앱의 좌/우측에서 서랍처럼 꺼내서 사용할 수 있는 메뉴이다.

원래 우리 설정 페이지는 이렇게 다른 페이지들에 비해 휑한 느낌을 주고 있었고

  • 인사말
  • 어떤 소셜 로그인을 이용해 로그인 했는지
  • 로그아웃 버튼

요 설정 페이지에서 우측 상단 점세개 버튼을 누르면 설정 더보기가 열려 탈퇴/문의 등을 할 수 있는 식이었다.

주요 내용들을 서랍 메뉴로 빼도 복잡하지 않을 것 같았다.




🤔 ReactNative : Drawer Navigator vs. Drawer Layout

처음에 작업을 하면서는 Drawer Navigator에 대해서 찾아보았는데
실제 작업 시에는 Drawer Layout을 사용했다.

말 그대로 드로어 네비게이터는 서랍 메뉴를 이용해 페이지 이동을 할 필요가 있을 때 사용하면 될 것 같고, 실제로 우리 앱에서 서랍 메뉴 내 기능들은 페이지 이동이 필요 없기 때문에 레이아웃만 사용한다.

(앱에서는 이미 가장 상위에서 Tab Navigator를 사용하고 있고, 플레이리스트 페이지 내에서는 뒤로가기 버튼을 사용하기 위해 부분적으로 Stack Navigator를 사용하고 있다.)

레이아웃을 사용하기 위해서는 아래 명령어를 이용해 패키지 설치 및 초기 세팅을 해준다. (자세한 내용은 위에 연결해둔 공식문서 링크로 가면 확인 가능)

//1. 레이아웃 패키지 설치
npm install react-native-drawer-layout

//2. 관련 패키지도 설치 
//우리 앱은 expo를 사용하므로 아래 명령어를 사용함 순수 RN만 사용하는 경우에는 npm install 명령어를 사용한다 
npx expo install react-native-gesture-handler react-native-reanimated

//3. index.js or App.js 에 패키지 import 
import 'react-native-gesture-handler';




Drawer Menu GlobalState

처음에 서랍의 열림 상태를 관리할 때에는 Tab Navigator가 존재하는 Home.js 내에서 useState를 사용했는데, Stack Navogator를 사용하고 있는 플레이리스트 페이지에서도 Drawer의 열고 닫힘을 관리하려면 전역 상태로 관리가 필요했다. 모달과 비슷하지만 모달보다도 훨씬 간단한 DrawerState를 만들었다.

//recoil - DrawerState
import { atom } from 'recoil';

const DrawerState = atom({
  key: 'DrawerState',
  default: false,
});

export default DrawerState;

공식문서를 읽어보면 알 수 있듯이 Drawer 컴포넌트의 props에는 open, onOpen, onClose 가 있고, 이 props들을 사용해서 열고 닫으면 된다.

서랍 내부를 커스텀 하기 위해서는 renderDrawerContent props를 이용하면 된다.
생각보다 drawer 내에 들어가는 기능들에 필요한 코드가 많았는데 Home에 줄줄 늘어놓기보다는 drawerContent 컴포넌트를 만들어서 사용했다.

//DrawerContent.js

const DrawerContent = () => {
  const { userId, email, loginType } = useRecoilValue(userInfo);
  const resetUserInfo = useResetRecoilState(userInfo);
  const resetPlayList = useResetRecoilState(userPlayList);
  const resetModal = useResetRecoilState(ModalState);
  const setModal = useSetRecoilState(ModalState);
  const server = useServer();

  //로그아웃
  const confirm = confirmProps(
    '로그아웃',
    '로그아웃 하시겠습니까?',
    '확인',
    async () => {
      await logoutHandler();
    },
  );

  const logoutHandler = async () => {
    await AsyncStorage.clear();
    resetUserInfo();
    resetPlayList();
    resetModal();
    navigation.navigate('Populer');
    makeToast('로그아웃이 완료되었습니다.');
  };

  //회원탈퇴
  const leaveHandler = async () => {
    try {
      await server.delete(`/api/users/${userId}`);
    } catch (error) {
      console.log(error);
    }
  };

  const confirmLeave = confirmProps(
    '회원 탈퇴',
    '탈퇴 하시겠습니까?',
    '확인',
    async () => {
      await leaveHandler();
      await AsyncStorage.clear();
      resetUserInfo();
      resetPlayList();
      resetModal();
      navigation.navigate('Populer');
      makeToast('회원 탈퇴가 완료되었습니다.');
    },
  );

  //문의하기
  const sendEmail = async () => {
    let options = {
      subject: '문의사항 제목을 입력해주세요',
      recipients: ['conopli.dev@gmail.com'],
      body: '문의사항 내용을 입력해주세요',
    };

    let promise = new Promise((resolve, reject) => {
      MailComposer.composeAsync(options)
        .then((result) => {
          resolve(result);
        })
        .catch((err) => {
          reject(err);
        });
    });

    let statusText = (type) => {
      switch (type) {
        case 'cancelled':
          return '문의 메일이 임시저장되지 않았습니다';
        case 'saved':
          return '문의 메일이 임시저장되었습니다';
        case 'sent':
          return '문의 메일이 전송되었습니다';
        case 'undeterminded':
          return '오류가 발생했습니다';
      }
    };

    promise.then(
      (result) => {
        const message = statusText(result.status);
        makeToast(message);
      },
      (err) => {
        makeToast(`에러: ${err}의 이유로 문제가 발생했습니다`, true);
      },
    );
  };

  return (
    <View style={styles.container}>
      <EmailBadge email={email} loginType={loginType} />
      <View style={styles.buttonBox}>
        <View style={styles.buttonItem}>
          <RowButton
            text="로그아웃"
            color="red"
            buttonHandler={() => {
              setModal(confirm);
            }}
          />
        </View>
        {userId !== 0 && (
          <View style={styles.buttonItem}>
            <RowButton
              text="탈퇴하기"
              color="lightGray"
              buttonHandler={() => {
                setModal(confirmLeave);
              }}
            />
          </View>
        )}
        <View style={styles.buttonItem}>
          <RowButton
            text="문의하기"
            color="lime"
            buttonHandler={() => {
              resetModal();
              sendEmail();
            }}
          />
        </View>
      </View>
    </View>
  );
};

export default DrawerContent;

홈에 적용하기

//Home.js

const Home = () => {
  const Tab = createBottomTabNavigator();
  const { userId } = useRecoilValue(userInfo);
  const [isOpen, setIsOpen] = useRecoilState(DrawerState);

  return (
    <NavigationContainer>
      <Drawer
        open={isOpen}
        onOpen={() => setIsOpen(true)}
        onClose={() => setIsOpen(false)}
        drawerType="front"
        drawerStyle={{ backgroundColor: theme.background }}
        renderDrawerContent={() => {
          return (
            <View style={{ paddingHorizontal: 32, paddingVertical: 50 }}>
              <View>
                <DrawerContent />
              </View>
            </View>
          );
        }}
      >
        <Tab.Navigator
          initialRouteName="Populer"
          screenOptions={({ route, navigation }) => ({
            tabBarActiveTintColor: theme.lime,
            tabBarInactiveTintColor: theme.violet,
            tabBarStyle: {
              backgroundColor: theme.black,
              paddingTop: 5,
            },
            tabBarLabelStyle: {
              fontFamily: 'Pretendard-400',
              fontSize: 10,
              paddingBottom: 5,
            },
            headerStyle: {
              backgroundColor: theme.black,
              elevation: 0,
              shadowColor: 'rgba(0, 0, 0, 0)',
            },
            headerTitleStyle: {
              fontFamily: 'Pretendard-600',
              fontSize: 24,
              color: theme.white,
            },
            headerTitleAlign: 'center',
            headerLeft: () => (
              <TouchableOpacity
                onPress={() => {
                  setIsOpen((prev) => !prev);
                }}
                style={{ marginLeft: 16 }}
              >
                <Entypo name="menu" size={24} color={theme.white} />
              </TouchableOpacity>
            ),
          })}
        >
          //중략 (내부에는 Tab.Screen 이 존재)
        </Tab.Navigator>
        <GlobalModal />
      </Drawer>
    </NavigationContainer>
  );
};

export default Home;

screenOptions 내에 headerLeft로 서랍 버튼을 만들어줄 때에 렌더링이 안 되길래 뭐지 했는데 Entypo 아이콘의 기본 컬러가 검정이라 렌더링 되었는데 보이지 않았던 것이었다... ㅎㅎ

위에서도 언급했듯이 플레이리스트 쪽은 Stack Navigator를 사용하고 있어서 listHome.js 라는 페이지가 있고, 이 페이지 내에서 Stack Navigator를 관리하고 있다.

플레이리스트 내부에서는 좌측 상단에 항상 뒤로가기 버튼 (<)이 존재하기 때문에 플레이리스트 홈에서만 서랍 메뉴가 표시되었으면 했다. 그래서 그냥 Stack.screen에 박아줌.

//listHome.js 내 Stack.Screen (많이 생략됨)

import { theme } from '../theme';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { TouchableOpacity } from 'react-native';
import DrawerState from '../recoil/drawer';
import { useSetRecoilState } from 'recoil';
import { Entypo } from '@expo/vector-icons';

const ListHome = () => {
  const Stack = createNativeStackNavigator();
  const setIsOpen = useSetRecoilState(DrawerState);

  return (
    <Stack.Navigator
      initialRouteName="Playlist"
      screenOptions={{
        headerStyle: {
          backgroundColor: theme.black,
        },
        headerTitleStyle: {
          fontFamily: 'Pretendard-600',
          fontSize: 24,
          color: theme.white,
        },
        headerTitleAlign: 'center',
        headerTintColor: theme.white,
        animation: 'slide_from_right',
      }}
    >

<Stack.Screen
        name="Playlist"
        component={Playlist}
        options={{
          title: '내 플레이리스트',
          headerLeft: () => (
            <TouchableOpacity
              onPress={() => {
                setIsOpen((prev) => !prev);
              }}
            >
              <Entypo name="menu" size={24} color={theme.white} />
            </TouchableOpacity>
          ),
        }}
      />

테스트로 서랍관련 코드를 복붙해와서 Stack Navigator 내에서 적용하면 탭 쪽이 씹히는데, 전역 상태를 사용해서 관리하게 되면 Home에 씌워진 Drawer를 활용하는 거라서 다른 탭들에서와 마찬가지로 가장 상위에서 열린다.

지금보니 Entypo 서랍 버튼도 컴포넌트화 하면 좋을 거 같다...
주중에 디자인 적용하면서 같이 해야징.

profile
키보드로 그려내는 일

0개의 댓글