ui 라이브러리의 컴포넌트를 구현하다 생긴 고민.

August 11, 2021

dooboo-ui

최근 오픈소스 컨트리뷰톤 이라는 프로그램에 참여해서 멘토링을 받으며 dooboo-ui에 기여할 기회가 생겼다.

dooboo-ui는 React-Native 기반의 ui 컴포넌트 라이브러리 프로젝트로서 React-Native UI 컴포넌트들을 공용으로 정의해두고 쉽게 이용할 수 있도록 한다. 내부 스택은 emotion, typescript, jest, react-testing-library, expo, storybook, 등을 이용하고, dooboolab 에서 주로 관리하고있는 오픈소스 라이브러리이다.

First Issue

가장 먼저 dooboo-ui 프로젝트를 fork하고 로컬 환경을 set 한 뒤 storybook으로 구성된 모바일 preview를 둘러보던 중 checkbox의 반응형이 조금 이상한 것 같아 이를 수정해 PR을 날렸고 바로 반영이 되었다. dooboo-ui 첫 PR

물론 회사에서 매일 개발을 하고 있긴 했지만 회사 밖의 개발자들과 소통하고 기여할 수 있다니.. 새로운 성취감을 느꼈다.

First Issue Report

첫 이슈를 발견해서 해결한 후 자신감이 붙어 조금 더 적극적으로 이슈를 물색하고 구현하려고 했다. 그러던 중 일반적으로 모바일 환경에서 많이 사용하는 Floating Action Button (FAB)가 없는 것을 보고 이를 구현하는 것을 제안했다.그리고 가능하면 나에게 할당해서 기여 기회를 달라고 어필했다. dooboo-ui 첫 Issue Report

구현 그리고 고민…

그리고 내 바램대로 바로 할당을 받고 구현을 시작했는데, 구현을 시작한지 얼마 되지않아 바로 고민에 빠졌다. 사실 이번은 두번째 FAB 구현인데 첫 구현은 props로 아이콘과 callback을 받고 이를이용해 내부에서 ButtonView들을 만들어 주는 구조로 구현을 했었다. 이렇게 개발을 했을때는 내부에서 ButtonView를 만들어주기때문에 버튼 뷰들의 스타일을 일관적으로 가져갈 수 잇다는 장점이 있고, 해당 컴포넌트를 사용하는방법을 제한함으로써 정확히 FAB의 용도로만 사용할 수 있도록 강제할 수 있다는 장점이 있었다. 반대로 사용방법을 제한함으로써 유연성을 저해한다는 단점은 있는 것 같다.

export const FloatingActionButtonImpure: FC<FloatingActionButtonImpureProps> =
  enhance<FloatingActionButtonImpureProps>(() => {
    const [isActive, setIsActive] = useState(false)

    const onClickFloatingButton = useCallback(() => {
      setIsActive((prev) => !prev)
    }, [])

    const menus = useMemo(
      () => [
        {
          icon: <PlaylistPlay />,
          onClick: () => {
            //
          },
        },
        {
          icon: <Settings />,
          onClick: () => {
            //
          },
        },
        {
          icon: <Information />,
          onClick: () => {
            //
          },
        },
      ],
      []
    )

    return (
      <x.div>
        <FloatingActionButtonPure
          icon={isActive ? <Minus /> : <Add />}
          onClick={onClickFloatingButton}
          active={true}
          index={-1}
        />
        {menus.map((menu, idx) => (
          <FloatingActionButtonPure
            key={idx}
            icon={menu.icon}
            onClick={menu.onClick}
            active={isActive}
            index={idx}
          />
        ))}
      </x.div>
    )
  })(FloatingActionButtonFallback)

이번에는 라이브러리이기 때문에 유연성을 더 주어야 한다고 생각해서 아래와 같이 컴포넌트 자체를 받도록 하고, callback, 등은 외부에서 알아서 넘겨주는 컴포넌트에 바인딩을 하도록 의도해서 첫 커밋을했다. 이렇게 하니 유연성은 늘어난 것 같은데, 사용자가 어떤 컴포넌트를 넘겨줄지도 확실하지 않기때문에 스타일의 통일성이나 버튼 자체의 사용용도를 완전히 다르게 사용할 수 있을 것 같다는 생각이 든다.

일단 아래 방향으로 구현을 진행하다가 PR을 날려서 구현 방향에 대해서 다른 컨트리뷰터들과 maintainer에게 질문해봐야겠다.

import {DoobooTheme, light, withTheme} from '../theme';
import React, {FC} from 'react';

import {ViewStyle} from 'react-native';
import styled from '@emotion/native';

const FixedPositionWrapperView = styled.View`
  position: fixed;
  top: ${(props: ViewStyle) => props.top};
  bottom: ${(props: ViewStyle) => props.bottom};
  left: ${(props: ViewStyle) => props.left};
  right: ${(props: ViewStyle) => props.bottom};
`;

const ExpendWrapperView = styled.View``;

interface FloatingActionButtonWrapperProps {
  active: boolean;
  DefaultMainActionButton: React.ReactElement;
  ActiveMainActionButton: React.ReactElement;
  ActionButtons: Array<React.ReactElement>;
  top?: number;
  bottom?: number;
  left?: number;
  right?: number;
}

const FloatingActionButtonWrapper: FC<
  FloatingActionButtonWrapperProps & {theme: DoobooTheme}
> = ({
  active,
  DefaultMainActionButton,
  ActiveMainActionButton,
  ActionButtons,
  top,
  bottom,
  left,
  right,
}) => {
  return (
    <FixedPositionWrapperView style={{top, bottom, left, right}}>
      {active ? ActiveMainActionButton : DefaultMainActionButton}
      <ExpendWrapperView>
        {ActionButtons.map((ActionButton) => ActionButton)}
      </ExpendWrapperView>
    </FixedPositionWrapperView>
  );
};

FloatingActionButtonWrapper.defaultProps = {theme: light};

export const IconButton = withTheme(FloatingActionButtonWrapper);

20210814 -추가 아이디어

저번 금요일에 dooboolab에 직접 가서 메인테이너와, 그리고 컨트리뷰톤 참가자들과 함께 위 내용에 대해서 논의해볼 시간이 생겼다. 위 내용에 대해서 고민을 해본 후 얻게된 고찰은 위의 두 장점을 합쳐서 하나의 컴포넌트를 제공할 수 있다는 점이었다.

export type FABItem = {
  icon: IconName;
  onPress: () => void;
};

interface FloatingActionButtonsWrapperProps {
  active: boolean;
  DefaultFAB: FABItem;
  ActiveFAB: FABItem;
  FABList: FABItem[];
  renderDefaultFAB?: (item: FABItem) => React.ReactElement;
  renderActiveFAB?: (item: FABItem) => React.ReactElement;
  renderFABListItem?: (item: FABItem, idx: number) => React.ReactElement;
}

const FloatingActionButtons: FC<FloatingActionButtonsWrapperProps> = ({
  active,
  DefaultFAB,
  ActiveFAB,
  FABList,
  renderDefaultFAB,
  renderActiveFAB,
  renderFABListItem,
}) => {
  const defaultFAB: React.ReactElement = useMemo(
    () =>
      renderDefaultFAB ? (
        renderDefaultFAB(DefaultFAB)
      ) : (
        <IconButton
          icon={<StyledIcon size={24} name={DefaultFAB.icon} />}
          onPress={DefaultFAB.onPress}
        />
      ),
    [renderDefaultFAB, DefaultFAB],
  );

  const activeFAB: React.ReactElement = useMemo(
    () =>
      renderActiveFAB ? (
        renderActiveFAB(ActiveFAB)
      ) : (
        <IconButton
          icon={<StyledIcon size={24} name={ActiveFAB.icon} />}
          onPress={ActiveFAB.onPress}
        />
      ),
    [renderActiveFAB, ActiveFAB],
  );

  return (
    <AbsolutePositionWrapperView>
      <Animated.View>
        {active &&
          FABList.map((item, idx) =>
            renderFABListItem ? (
              <FABItemWrapperView key={item.icon + idx}>
                {renderFABListItem(item, idx)}
              </FABItemWrapperView>
            ) : (
              <FABItemWrapperView key={item.icon + idx}>
                {
                  <IconButton
                    icon={
                      <StyledIcon theme={theme} size={24} name={item.icon} />
                    }
                    onPress={item.onPress}
                  />
                }
              </FABItemWrapperView>
            ),
          )}
      </Animated.View>
      <FABItemWrapperView>{active ? defaultFAB : activeFAB}</FABItemWrapperView>
    </AbsolutePositionWrapperView>
  );
};

export const FAB = withTheme(FloatingActionButtons);

위의 내용을 보면 기본적으로 callback 과 아이콘을 props로 넘기게 함으로써 dooboo-ui 에서 제공하는 스타일과 컴포넌트의 사용의도록 strick 하게 제한 할 수 있다. 이와 동시에 renderFABListItem, renderActiveFAB, renderDefaultFAB 등을 체크해서 callback 들이 구현되어있다면 위 callback을 통해 해당 컴포넌트의 사용자가 원하는데로 item을 정의할 수 있도록 인터페이스를 제공할 수 있다. 이렇게 함으로써 위에서 고민했던 두가지 장점을 모두 합칠 수 있는 것 같다.

메인테이너님이 추천해주신 레퍼런스 컴포넌트는 페이스북에서 만든flatlist였는데 해당 컴포넌트를 보아도 같은 방식을 사용하는 것을 볼 수 있었다. 많은 테크닉들이 있는데 여러 레퍼런스들과, 커뮤니티 들을 통해 고민들을 공유하고 이를 갈고닦는 것이 중요하다는 생각이 들었다.


Written by Juyeong Byeong . github