고윤태의 개발 블로그

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

개발

React Native Chip을 구현해보자

고윤태 2023. 5. 26. 18:01

개요

안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 제가 흔히 Chip이라 불리는 컴포넌트를 어떤 식으로 구현했고 어떤 식으로 사용하고 있는지 전달하고자 이렇게 글을 작성하게 됐습니다.


개발 환경

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


구현하게 된 계기

요즘 진행 중인 토이 프로젝트가 하나 있습니다. 지난 글에서도 작성을 하긴 했지만
이 토이 프로젝트에서 저희가 사용하게 될 디자인 시스템에

 

이런 모양의 사람들이 흔히 Chip이라고 부르는 요소가 추가되었습니다.

 

이렇게 구글에 자료가 너무나도 많지만
제가 개인적으로 라이브러리에 의존적인 걸 좋아하지 않습니다.

라이브러리는 우리의 개발을 편하고 빠르게 도와주는 좋은 도구지만 지금 사용하는 라이브러리가 이대로 영원하지도 않고 기본적으로 원리도 모르는 상태로 라이브러리만 가져다가 붙이고 "끝"이라고 하는 것을 별로 좋아하지 않습니다

 

이러한 이유로 제가 직접 구현하기로 마음을 먹었습니다.


기능 추출

출처(https://m3.material.io/components/chips/overview)

google material design 디자인 시스템의 component의 Chip에 대한 설명을 보시면

출처(구글 번역기)

이런 식으로 정의하고 있습니다.
이 정보와 디자인 시스템에 맞춰서 개발을 해보도록 하겠습니다.


구현

props

interface Props {
  label: string;
  onPressHandler?: () => void;
  isActive?: boolean;
  style?: ViewStyle;
}
  • label : Text로 보일 문구 입니다.
  • onPressHandler : press 시 발생 시킬 이벤트입니다.
  • isActive : 위에 디자인 시스템에 Chip 컴포넌트를 보시면 background color 가 화이트인 것과 주황색인 것이 있는데 활성 여부로 background color가 나뉘고 있어서 활성 여부를 받습니다.
  • style : Chip의 margin 또는 padding width 등을 조절할 때 사용하기 위해 넣었습니다.

코드

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

interface Props {
  label: string;
  onPressHandler?: () => void;
  isActive?: boolean;
  style?: ViewStyle;
}

const Chip = (props: Props) => {
  const { label, onPressHandler, isActive = false, style } = props;

  return (
    <Pressable
      onPress={onPressHandler}
      style={[
        styles.wrap,
        {
          borderColor: isActive ? Color.white : Color.primary700,
          backgroundColor: isActive ? Color.primary700 : Color.white,
        },
        style,
      ]}
    >
      <Text
        style={[
          TYPOS.small,
          { color: isActive ? Color.white : Color.primary700 },
        ]}
      >
        {label}
      </Text>
    </Pressable>
  );
};

export default Chip;

const styles = StyleSheet.create({
  wrap: {
    alignSelf: "flex-start",
    paddingHorizontal: 20,
    paddingVertical: 8,
    borderWidth: 1,
    borderRadius: 24,
  },
});

코드 설명

Pressable 을 이용해 onPress event를 호출 시킬 수 있도록 하고 isActive 값을 이용해 backgroudColor, borderColor, Text의 값을 할당하고 있습니다.
제가 여기서 생각하는 포인트 코드는 위에 제가 올려놓은 디자인 시스템의 Chip의 디자인을 보시면 활성화가 되지 않은 경우 borderColor가 주황색 backgroundColor가 하얀색으로 구성이 되어있는데 이런 경우 isActive가 false인 경우에만 borderColor와 borderWidth의 값을 할당하시는 분들이 계실 수도 있습니다.
그런 경우에 isActive의 여부에 따라서 border의 값으로 인해 실제 사용자에게 보이는 크기가 차이가 날 수 있습니다.

 

예시를 보여드리겠습니다.

Chip의 style을 위에 작성한 상황을 만들기 위해서 코드를 이런 식으로 변경해봤습니다.

      style={[
        styles.wrap,
        {
          borderColor: isActive ? Color.white : Color.primary700,
          backgroundColor: isActive ? Color.primary700 : Color.white,
          ...(!isActive && { borderWidth: 1 }),
        },
        style,
      ]}

 

그 후 이런 식으로 방금 만든 Chip을 사용해봤습니다.

      <Chip label="Chip" style={{ marginBottom: 16, marginLeft: 16 }} />
      <Chip
        label="Chip"
        style={{ marginBottom: 16, marginLeft: 16 }}
        isActive={true}
      />

 

결과 화면

이렇게 border로 인해 크기 차이가 나버립니다 웹에서 개발한다면 border를 사용하는 것이 아닌 box-shadow를 사용해 inline-border 효과를 내서 사용하는 방법도 있겠지만

저는 제가 작성한 코드대로 border를 미리 할당은 하나 background 컬러랑 맞춰서 구현하는 방법을 많이 선택하는 편입니다.


응용하기

제가 현재 프로젝트에서 Chip을 사용하는 곳들은 이런 곳입니다.

화면1

 

화면2

화면을 보시면 UI 자체는 Chip이지만 마치 Radio 버튼처럼 하나만 선택될 수 있도록 해야 하는 상황입니다.

그래서 저 같은 경우 관리하기 쉽게 Container 컴포넌트를 하나 만들면 좋을 거 같아서 진행해 보도록 하겠습니다.


구상

 

현재 나와있는 디자인에서는 Chip을 Radio처럼 단일 선택 밖에 존재하지 않지만 추가적인 디자인 작업을 하다 보면 다중 선택이 가능하게 되는 케이스도 존재할 것이라고 가정하고 구상을 했습니다.

1차 구상 단계는
Container 컴포넌트를 2개를 만드는 방향으로 생각했습니다.
단일과 다중 예시로 SingleChipContainer, MultiChipContainer 이런 식으로

처음에 저렇게 생각했던 이유는 어느 정도의 코드 반복이라는 단점은 있지만 네이밍의 직관화 추후 유지 보수 편의성이라는 장점이 있습니다.

 

하지만 제 개인적인 생각에 Chip을 단일 선택할지 다중 선택할지의 기능에서 추가적인 기능이 생기지 않을 것으로 판단이 되어서
ChipContainer 하나로 통합하여 개발을 진행하기로 했습니다.

 


코드

import React from 'react';
import { ViewStyle, View } from 'react-native';
import Chip from './Chip';

type SingleSelectedHandler = (label: string) => void;
type MultiSelectedHandler = (labels: string[]) => void;

interface Props {
  labels: string[];
  type: 'single' | 'multi';
  selectedLabel?: string | string[];
  onSelectedHandler?: SingleSelectedHandler | MultiSelectedHandler;
  containerStyle?: ViewStyle;
  chipStyle?: ViewStyle;
}

const ChipContainer = ({
  labels,
  type,
  selectedLabel,
  onSelectedHandler,
  containerStyle,
  chipStyle,
}: Props) => {
  const onPressHandler = (isActive: boolean, label: string) => {
    if (type === 'single') {
      if (isActive) {
        (onSelectedHandler as SingleSelectedHandler)?.('');
      } else {
        (onSelectedHandler as SingleSelectedHandler)?.(label);
      }
    } else if (type === 'multi') {
      const selectedLabels = Array.isArray(selectedLabel)
        ? [...selectedLabel]
        : [];

      if (isActive) {
        const index = selectedLabels.indexOf(label);
        if (index !== -1) {
          selectedLabels.splice(index, 1);
        }
      } else {
        selectedLabels.push(label);
      }

      (onSelectedHandler as MultiSelectedHandler)?.(selectedLabels);
    }
  };

  return (
    <View style={containerStyle}>
      {labels.map((label) => {
        const isActive = Array.isArray(selectedLabel)
          ? selectedLabel.includes(label)
          : selectedLabel === label;

        return (
          <Chip
            key={label}
            label={label}
            style={chipStyle}
            isActive={isActive}
            onPressHandler={() => onPressHandler(isActive, label)}
          />
        );
      })}
    </View>
  );
};

export default ChipContainer;

코드 설명

 

props의 type을 통해 단일인지 다중 선택인지 받도록 하였습니다.
type을 통해 Chip onPress event를 분할했습니다.

 

  • single(단일)
    • isActive - true : 선택 해제
    • isActive - false : 선택
  • multi(다중)
    • isActive - true : 선택 해제
    • isActive - false : 선택



labels를 이용하여 map을 통해 Chip을 생성할 때
isActive 값을 Array.isArray로 Array인 경우와 String인 경우를 분할했습니다
단일인 경우 String을 받지만 다중인 경우 String[]를 받기 때문입니다.

 

String method에도 includes는 존재하지만 단어 그대로 포함하는가?를 보기 때문에 "chip"이라는 값이 props의 selectedLabel에 넘어온다면 chip1, chip2 둘 다 모두 활성화가 되기 때문에 ===으로 비교하도록 했습니다.

사용 예시

 

const App = () => {
  const [selectedItem, setSelectedItem] = useState("");
  const [selectedItems, setSelectedItems] = useState<string[]>([]);

  return (
    <Container>
      <View style={{ marginLeft: 16, marginBottom: 16 }}>
        <Text style={{ marginBottom: 16 }}>single</Text>
        <ChipContainer
          containerStyle={{ flexDirection: "row" }}
          chipStyle={{ marginRight: 16 }}
          type="single"
          labels={["chip1", "chip2"]}
          selectedLabel={selectedItem}
          onSelectedHandler={(item: string) => {
            setSelectedItem(item);
          }}
        />
      </View>

      <View style={{ marginLeft: 16, marginBottom: 16 }}>
        <Text style={{ marginBottom: 16 }}>multi</Text>
        <ChipContainer
          containerStyle={{ flexDirection: "row" }}
          chipStyle={{ marginRight: 16 }}
          type="multi"
          labels={["chip3", "chip4"]}
          selectedLabel={selectedItems}
          onSelectedHandler={(items: string[]) => {
            setSelectedItems(items);
          }}
        />
      </View>
    </Container>
  );
};

export default App;

저는 이런 식으로 사용했습니다.

그렇다면 이런 식으로 화면이 보이게 됩니다.

Ex gif

처음에 구상대로 단일 선택 또는 다중 선택에 따라서 의도대로 잘 동작하는 것을 확인할 수 있습니다.


Chip을 다른 방법으로 구현하기

 

지금 제가 구현한 Chip 같은 경우 선택하면 사용자의 눈에 보이는 Label 자체를 onPress Handler 함수에 parameter로 보내주고 있습니다.

그러나 이러한 경우도 충분히 있습니다.
사용자 눈에는 '딸기'라고 보이지만 front 단에서는 'strawberry'라고 state에 보관을 하고 싶은 상황이


제 코드를 그대로 사용하시게 된다면 아마 이런 식으로 해결하시지 않을까 싶네요

const App = () => {
  const [selectedItem, setSelectedItem] = useState("");

  const showSelectedFruit = () => {
    switch (selectedItem) {
      case "딸기":
        console.log("strawberry");
        break;
      case "오렌지":
        console.log("orange");
        break;
      case "사과":
        console.log("apple");
        break;
      default:
        console.log("선택 된 과일이 없어요");
    }
  };

  return (
    <Container>
      <View style={{ marginLeft: 16, marginBottom: 16 }}>
        <Text style={{ marginBottom: 16 }}>과일</Text>
        <ChipContainer
          containerStyle={{ flexDirection: "row" }}
          chipStyle={{ marginRight: 16 }}
          type="single"
          labels={["딸기", "오렌지", "사과"]}
          selectedLabel={selectedItem}
          onSelectedHandler={(items: string) => {
            setSelectedItem(items);
          }}
        />
      </View>
      <TouchableOpacity onPress={showSelectedFruit}>
        선택한 과일은?
      </TouchableOpacity>
    </Container>
  );
};

export default App;

간단하게 예시로 작성한 코드이고 이 방법을 제외하고도 굉장히 많습니다.
하지만 저 하나를 위해 이런 결이 비슷한 코드가 무수히 쌓여갈 바에는 Chip 컴포넌트를 개선하는 게 맞습니다.

 

제가 생각한 개선 방법을 간단하게 공유드립니다.

 

export interface Required {
  label: string;
  value: string;
}

interface Props<T> {
  attribute: T;
  onPressHandler?: (t?: T) => void;
  isActive?: boolean;
  style?: ViewStyle;
}

const Chip = <T extends Required>(props: Props<T>) => {
  const { attribute, onPressHandler, isActive = false, style } = props;

Chip 컴포넌트를 제네릭으로 변경 후 Required라는 interface를 제네릭에 상속받게 하여 label과 value은 필수인 Type을 사용하도록

원래는 label이라는 props를 text에 보여줬지만 이 코드에서는 attribute라는 값에 label이 존재합니다.

 

      <Text
        style={[
          TYPOS.small,
          { color: isActive ? Color.white : Color.primary700 },
        ]}
      >
        {attribute.label}
      </Text>

이런 식으로 변경하여 사용합니다.

 

onPress의 경우도

      onPress={() => {
        if (onPressHandler) {
          onPressHandler(attribute);
        }
      }}

 

이런 식으로 attribute를 그대로 넘기도록 변경합니다. Chip을 사용하는 곳에서는 필요할 수도 있고 아닌 경우도 있기 때문에 옵셔널로 처리해놨습니다.

 

ChipContainer도

interface Props<T> {
  items: T[];
  type: "single" | "multi";
  selectedValue?: string | string[];
  onSelectedHandler?: SingleSelectedHandler | MultiSelectedHandler;
  containerStyle?: ViewStyle;
  chipStyle?: ViewStyle;
}

const ChipContainer = <T extends Required>({
  items,
  type,
  selectedValue,
  onSelectedHandler,
  containerStyle,
  chipStyle,
}: Props<T>) => {

이런 식으로 변경했습니다.

 

Chip을 렌더링하는 부분의 코드는 이렇게 변경하여 사용했습니다. 

    <View style={containerStyle}>
      {items.map((item) => {
        const isActive = Array.isArray(selectedValue)
          ? selectedValue.includes(item.value)
          : selectedValue === item.value;

        return (
          <Chip
            key={item.value}
            attribute={item}
            style={chipStyle}
            isActive={isActive}
            onPressHandler={() => onPressHandler(isActive, item.value)}
          />
        );
      })}
    </View>

제가 변경한 코드에 의하면 원래 의도대로 사용자에게 보이는 label 문구와 선택됐을 때 상태에 저장하는 value를 다르게 구분하여 사용할 수 있게 됩니다.

저 같은 경우 이런 식으로 해결할 것 같습니다. 더 좋은 피드백 있으시면 언제든 댓글 남겨주시면 감사하겠습니다.

후기

UI를 구성할 때 생각보다 자주 사용하게 되는 Chip으로 제가 이런 긴 글을 쓰게 될지 몰랐습니다. 처음에는 Chip 만드는 법만 작성을 하려 했는데 사실 Chip의 Ui 구성 같은 경우 저보다 이미 좋은 글이 굉장히 많습니다.

제가 그래서 제 글을 읽어주시는 분들에게 서로 지식 공유를 어떻게 하면 더 할 수 있을까 하다 실제 개발 시 편하게 사용하고자 만들게 된 ChipContainer를 포함하여 글을 쓰게 되었고 글을 작성하는 도중
실제 radio 버튼처럼 label과 value를 나누고 싶어 하시는 분들이 계실 거 같다고 생각이 들어서 추가하여 작성했습니다.

읽어주셔서 감사합니다.