고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

React Native Dropdown 구현하기

고윤태 2023. 7. 18. 17:34
안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 Dropdown을 어떤 계기로 구현하게 되었고 어떤 식으로 구현했는지에 대해 전달을 하고자 작성해 봅니다

💻 개발 환경

React-native(expo) + TypeScript로 진행되었습니다.

 

🧐 구현하게 된 계기

진행 중인 토이 프로젝트에서 디자인 시스템에 DropDown이 존재하였기에 개발을 하게 되었습니다.
훌륭한 라이브러리들이 많이 존재했지만 원하는 디자인과 디자인 시스템에 맞춰 개발을 하기 위해 직접 구현을 하기로 결정했습니다.

 

Dropdown Figma

 

🤔 기능 산출 및 개발 계획

현재 개발해야 하는 컴포넌트는 Figma를 통해서 확인할 수 있듯이 크게 2가지로 나누어집니다.
DropdownListValueListValue를 이용하여 Dropdown에서 선택할 수 있는 항목의 UI를 구성하게 됩니다.

Dropdown

  • 화면에 보이는 위치에 따라 ListValue 요소들을 아래에 노출시킬지 위에 노출시킬지 결정해야 함
  • Press 시 ListValue 요소들이 노출이 되어야 함
  • ListValue 요소들이 노출된 상태에서 OutSide를 Press 시 ListValue 요소들이 사라져야 함

ListValue

  • Actvie, Disabled 상태에 따라서 Font Color 변화
  • Press 시 Event Handler를 호출

이런 식으로 각 컴포넌트가 어떤 기능을 수행해야 하는지 정리해 봤습니다.

 

👀 컴포넌트 개발(CODE)

ListValue

import React from 'react';
import { Pressable, Text } from 'react-native';
import Color from '../../../constants/color';
import TYPOS from '../typo';

interface Props {
  label: string;
  isActive?: boolean;
  disabled?: boolean;
  onClickHandler?: (label: string) => void;
}

const ListValue = ({ label, isActive, disabled, onClickHandler }: Props) => {
  const getTextColor = () => {
    if (disabled) {
      return Color.neutral4;
    } else if (isActive) {
      return Color.primary700;
    } else {
      return Color.black;
    }
  };

  return (
    <Pressable
      style={{
        paddingHorizontal: 16,
        paddingVertical: 12,
      }}
      disabled={disabled}
      onPress={() => {
        if (onClickHandler) {
          onClickHandler(label);
        }
      }}
    >
      <Text style={[{ color: getTextColor() }, TYPOS.body1]}>{label}</Text>
    </Pressable>
  );
};

export default ListValue;

코드 설명

아래 사진과 같이

ListValue는 3가지의 상태가 있습니다. Default, Active, Disabled 이것을 각각 props의 isActive, disabled로 표현을 하였고

Text의 Color 값을 상태에 맞게 할당하도록 구현했습니다.

제가 getTextColor를 const와 IIFE(즉시 실행 함수)의 조합으로 구현한 이유는

개인적으로 let 변수를 좋아하지 않습니다. 그 이유는 const로 구현된 코드 같은 경우 변수를 본 뒤 여기서 변하지 않는다라는 확신을 가지고 읽을 수 있지만 let은 언제 어디서 바뀔지 신경을 써서 읽어야 하기 때문입니다.

 

개인적인 의견이 가득 담긴 말이니 무시하셔도 좋을 것 같습니다.


Dropdown

import React, { useRef, useState, useEffect } from 'react';
import {
  View,
  StyleSheet,
  ViewStyle,
  TouchableOpacity,
  Modal,
  TouchableWithoutFeedback,
  Dimensions,
  ScrollView,
} from 'react-native';
import Color from '../../../constants/color';
import useModal from '../../../hooks/useModal';
import ListValue from './ListValue';
import SHADOWS from '../shadow';

interface Props {
  list: string[];
  onLabelClickHandler?: (label: string) => void;
  selectedLabel?: string;
  layoutStyle?: ViewStyle;
  disabled?: boolean;
  placeholder?: string;
}

const FULL_HEIGHT = Dimensions.get('window').height;
const SCROLL_VIEW_MAX_HEIGHT = 240;

const Dropdown = ({
  list,
  selectedLabel = '',
  onLabelClickHandler,
  layoutStyle,
  disabled,
  placeholder,
}: Props) => {
  const { isVisible, openModal, closeModal } = useModal();
  const [dropdownTop, setDropdownTop] = useState(0);
  const [width, setWidth] = useState(0);
  const touchableOpacityRef = useRef<TouchableOpacity | null>(null);

  useEffect(() => {
    if (!isVisible) {
      return;
    }

    touchableOpacityRef.current?.measure(
      (_x, _y, width, height, _pageX, pageY) => {
        setWidth(width);

        if (
          FULL_HEIGHT -
            (pageY +
              height +
              12 +
              Math.min(SCROLL_VIEW_MAX_HEIGHT, list.length * 48)) >
          10
        ) {
          setDropdownTop(pageY + height + 12);
        } else {
          setDropdownTop(
            pageY - Math.min(SCROLL_VIEW_MAX_HEIGHT, list.length * 48) - 12
          );
        }
      }
    );
  }, [isVisible]);

  return (
    <View style={layoutStyle}>
      <TouchableOpacity
        activeOpacity={1}
        ref={touchableOpacityRef}
        disabled={disabled}
        onPress={openModal}
        style={[
          styles.fieldContainer,
          {
            ...(isVisible && {
              borderColor: Color.primary700,
            }),
            ...(disabled && {
              borderColor: Color.neutral4,
            }),
          },
        ]}
      >
        <ListValue
          label={selectedLabel}
          {...(!selectedLabel &&
            placeholder && {
              disabled: true,
              label: placeholder,
            })}
        />
      </TouchableOpacity>
      <Modal visible={isVisible} transparent animationType="fade">
        <TouchableWithoutFeedback onPress={closeModal}>
          <View
            style={{
              width: '100%',
              height: '100%',
              alignItems: 'center',
            }}
          >
            <View
              style={[
                {
                  width,
                  position: 'absolute',
                  zIndex: 10,
                  top: dropdownTop,
                  borderRadius: 8,
                  backgroundColor: Color.white,
                  maxHeight: SCROLL_VIEW_MAX_HEIGHT,
                },
                SHADOWS.shadow4,
              ]}
            >
              <ScrollView showsVerticalScrollIndicator={false}>
                {list.map((l) => (
                  <ListValue
                    key={l}
                    onClickHandler={(label) => {
                      closeModal();
                      if (onLabelClickHandler) {
                        onLabelClickHandler(label);
                      }
                    }}
                    label={l}
                  />
                ))}
              </ScrollView>
            </View>
          </View>
        </TouchableWithoutFeedback>
      </Modal>
    </View>
  );
};

export default Dropdown;

const styles = StyleSheet.create({
  fieldContainer: {
    height: 48,
    flexDirection: 'row',
    borderRadius: 4,
    borderWidth: 1,
    borderColor: Color.neutral3,
    alignItems: 'center',
  },
});

코드 설명

useEffect 부분에서 measure(참고 : https://reactnative.dev/docs/direct-manipulation#measurecallback)를 활용하여 width와 dropdown의 Top 위치를 계산하고 있습니다.

dropdown의 요소가 항상 아래에만 나오는 것이 아니라 만약 아래의 공간이 MaxHeight 보다 적다면 위로 나오도록

남은 공간을 계산하여 위에 나올지 아래에 나올지 dropdown이 보일 position을 계산하기 위해 사용했습니다.

 

완성된 Dropdown

위에 사진 2장을 보시면

하나는 아래에 LIstValue를 보여주기 충분한 공간이 있는 case

다른 하나는 공간이 없는 case입니다.

 

위에 제가 의도했던 대로 공간에 따라서 Dropdown이 펼쳐지는 방향이 정해졌습니다.

공간 O

 

공간 X

 

🗣 컴포넌트 설계 시 주관적인 의견 공유

만드는 순서가 중요할까?

라는 생각이 들 수 있지만 복잡한 컴포넌트를 제작할 때는 가장 작은 컴포넌트를 시작으로 상위 컴포넌트로 올라가며 제작하는 "상향식(bottom-up)"으로 제작하는 것이 좋다고 생각합니다.

상향식의 특징은 작은 단위의 컴포넌트들 만들 때 상위 컴포넌트의 형태에 얽매이지 않고 그 자체로 필요한 UI 요구사항만을 고려해서 만들 수 있다는 점입니다.

간단히 비유하자면 레고 조각을 먼저 만드는 과정이라고 할까요.

상위 컴포넌트는 레고 조각을 모아 만든 결과물이라고 생각합니다.

상향식으로 작업하면 컴포넌트 이름도 더 잘 지을 수 있다고 생각합니다.

컴포넌트 역할에 부합하는 직관적인 이름은 컴포넌트를 빠르게 파악할 수 있게 하고, 협업을 고려하면 더욱더 중요합니다.

 

컴포넌트 자체 요구사항들만 고려해서 만들면 요구사항들이 나타내는 역할로서의 이름을 먼저 떠올리게 됩니다. 우리의 경우에는 DropdownListValue 같은 상위 컴포넌트의 의존하는 이름보다 ListValue 같은 그 UI 자체를 나타내는 이름을 먼저 떠올리게 될 가능성이 높습니다.

이렇게 작업한 컴포넌트는 구현부는 물론이고 이름까지도 다른 컴포넌트와 의존성이 전혀 없는 컴포넌트가 된다고 생각합니다.

의존성이 없는 레고 블록은 얼마든지 활용해서 새로운 결과물을 만들 수 있다고 생각합니다.

 

😁 글 작성 후기

이번에 글 작성 방식(?)을 한 번 바꿔봤습니다.

그 이유는 제 취미 중 하나가 회사들의 기술 블로그를 읽는 것입니다.

다른 분들의 글을 읽다 보면 확실히 저보다 글 솜씨와 실력이 좋으셔서 그런지 글을 읽으면 막힘이 없으며 재밌게 읽을 수 있을 수 있는 반면에 제 글은 개인적인 느낌으로는 정리가 된 듯하지만 정작 필요한 물건이 필요한 경우 찾기는 어려운

마치 방이 겉으로 보기에 정리는 되어 있지만 현실은 서랍에 다 욱여넣은 듯한 그런 느낌을 받아서 다른 분들의 글과 글 쓰는 패턴(?) 같은 것을 참고해서 이번에 새로운 느낌으로 적어봤습니다.

아마도 제가 읽고 스스로 "오~" 소리가 나올 때까지는 계속 새로운 것을 시도해 볼 것 같네요

 

막상 Dropdown을 다 개발하고 난 후 글을 작성하고 나니

 

피그마에 이런 UI를 가진 화면이 추가되어 있네요

현재 개발이 되어 있는 Dropdown과 ListValue를 조금의 수정과 함께 유연하게 구현해 볼 생각입니다.

 

항상 제 글 읽어주셔서 감사합니다.

피드백은 언제든지 얼마든지 환영입니다.