[ReactNative] - UI 제작하기 (1) 3단 BottomSheet 만들기 - (hook, typescript 사용, three-step-bottom-drawer 리펙토링)

권준혁·2021년 10월 17일
3

ReactNative

목록 보기
4/4
post-thumbnail

개인공부를 목적으로 작성한 글입니다.
전체소스코드는 포함되어있지 않습니다

리액트네이티브로 앱개발을 하다보면 애니메이션을 추가해야하는 경우가 종종있다.
과도한 애니메이션은 자제해야겠지만
애니메이션을 통해서 사용자가 앱을 이해하는데 도움이 되거나
사용자 경험에서 좋은 인상을 남겨주려면 사용하는게 좋다.

리액트네이티브에서 공식적으로지원하는 Animations API, PanResponder API를 이용해서 여러가지 애니메이션을 만들어 보려고 한다.
하나의 앱내에서 여러가지 애니메이션을 사용해볼텐데 타입스크립트, 모듈 Alias 등 개발환경을 갖춘 뒤 진행하려고 한다.
꾸준히 관리하며 필요한 컴포넌트 기능들을 끌어다가 쓰기 위함이다.

ReactNative 공식홈페이지 - Animations

먼저 첫 번째로 이번 게시글에서는 Bottom Sheet 을 만들어보려고한다.
앱 화면 하단에서 끌어올리는 형태의 컴포넌트다.

직접 제작해본 UI동작

3단으로 되어있는 리액트 네이티브 라이브러리를 찾기가 쉽지않았는데
여기서 찾을 수 있었다.
하지만 유명한 라이브러리도 아니고 동작도 되지않았다. 유지보수도 안될뿐더러 클래스컴포넌트에 props-type을 사용하고 있었다.
에러나는 부분만 수정하고 사용하려 했으나..
또, Swipe하는 액션을 놓았을 때 계산로직과 상태값 변경이 몰려있어 조금만 컨텐츠가 늘어나도 끊기는 현상도 발생했었다.

결국 참고정도만 하고 새로 만들기로 했다.
하는 김에 기존 클래스형 컴포넌트를 함수형 컴포넌트
props-type을 타입스크립트로 바꾸기로 했다.
onRelease이벤트에서 동작하는 계산로직도 최대한 간소하게 해야겠다.
본론으로 들어가 개발환경 구축부터 리뷰해본다.


0. 개발환경 구축

ReactNative 공식문서: 환경설정에 따라 준비를 마치고 아래 명령어로 프로젝트를 생성한다.

타입스크립트를 이용할 것이기 때문에 --template react-native-template-typescript 옵션을 사용헌다.

react-native init uiTemplate --template react-native-template-typescript

타입스크립트 옵션을 깜빡했거나, 제대로 설치되지 않았다면 아래 과정을 추가한다.
타입스크립트와 타입정의가 포함된 라이브러리들을 설치한다.

yarn add --dev typescript @types/react @types/react-native

모듈 Alias 설정

Alias 설정을 위해서는 두 가지 설정을 해줘야하는데, 바벨타입스크립트다.

바벨은 컴파일과정에 참여한다. 바벨설정만 했고 릴리즈모드로 빌드한다면 path alias가 문제가 되지 않겠지만, 개발과정에서 Alias를 사용해 편의성을 높히기위한게 목적이기 때문에 타입스크립트 설정도 해주는 것이 좋다. (물론 타입스크립트도 에러를 뿜는다. Path Alias설정을 하기로 한 이상 꼭 해주자)

바벨에서 Alias를 사용하기 위해 필요한 플러그인을 설치한다.
yarn add --dev babel-plugin-module-resolver

다음 할 일은 바벨설정파일타입스크립트 설정파일을 수정하는 것이다.

바벨 설정파일

module.exports = {
  presets: ['module:metro-react-native-babel-preset'],
  plugins: [
    [
      'module-resolver',
      {
        root: ['.'],
        extensions: [
          '.ios.ts',
          '.android.ts',
          '.ts',
          '.ios.tsx',
          '.android.tsx',
          '.tsx',
          '.jsx',
          '.js',
          '.json',
        ],
        alias: {
          '~': './src',
          '@api': './src/api',
          '@assets': './src/assets',
          '@redux': './src/redux',
          '@screens': './src/screens',
          '@animation': './src/common/animation',
          '@components': './src/common/components',
          '@utils': './src/utils',
        },
      },
    ],
  ],
};

타입스크립트 설정파일

{
	// ... 생략 react-native-cli typescript옵션 기본설정사용
    "moduleResolution": "node",
    "baseUrl": "./src",
    "paths": {
      "~/*": ["./*"],
      "@api/*": ["api/*"],
      "@assets/*": ["assets/*"],
      "@navigation/*": ["navigation/*"],
      "@redux/*": ["redux/*"],
      "@screens/*": ["screens/*"],
      "@ui/*": ["ui/*"],
      "@animation/*": ["./common/animation/*"],
      "@components/*": ["./common/components/*"],
      "@utils/*": ["utils/*"]
    },
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
  },
  "exclude": [
    "node_modules",
    "babel.config.js",
    "metro.config.js",
    "jest.config.js"
  ]
}

제대로 설정을 마쳤다면 아래처럼 타입스크립트가 path를 잘 추천해준다.
나는 @문자를 prefix로 이용했다.


1. 본론 (UI만들기)

animations > bottomSheet 라는 폴더 안에 두 개의 파일이 있다.
애니메이션을 담당하는 Animator.tsx와 컨테이너 역할을 하는index.tsx로 구분했다.
index.tsx는 컴포넌트 사용자가 입력한 값에따라 Animator.tsx가 사용할 값들을 계산해서 넘겨준다.
예를들어 index.tsx 컴포넌트 props로 height값을 number타입으로 넘겼을 때, Animated.ValueXY같은 형태로 바꿔서 하위 컴포넌트에 전달하는 식이다.

컴포넌트의 재사용성을 늘리기 위해 children속성값을 이용한다.

Animator.tsx에서는 계산하는 로직은 생략하고 애니메이션만을 담당하고 index.tsx는 계산 로직만 담당하게했다.

1-1. Usage

const DIMENSIONHEIGHT = Dimensions.get('screen').height;
const TOP_CONTENT_HEIGHT = 300;
const FULL_HEIGHT = DIMENSIONHEIGHT;
const MID_HEIGHT = FULL_HEIGHT - TOP_CONTENT_HEIGHT;
const DOWN_HEIGHT = 200;

export default function BottomSheetCollection() {
  return (
    <View style={styles.container}>
      <BottomSheet
        containerHeight={FULL_HEIGHT}
        midHeight={MID_HEIGHT}
        downHeight={DOWN_HEIGHT}
        offset={0}
        showHandle={true}
        showShadow={true}>
        {/* children */}
        <View style={styles.flex} />
      </BottomSheet>
    </View>
  );
}

BottomSheet 컴포넌트의 속성타입

interface Props {
  containerHeight: number;
  midHeight: number;
  downHeight: number;
  offset?: number;
  children: ReactChild;
  showHandle?: boolean;
  showShadow?: boolean;
}

BottomSheet컴포넌트에 전달하는 showHandle, showShadow는 UI부분이니 알아서 사용하면 된다. BottomSheet의 위치를 결정하는 containerHeight, midHeight, downHeight, offset을 전달해주기만 하면 된다.


1-2. index.tsx

// ... import 및 type, interface 생략

const SCREEN_HEIGHT = Dimensions.get('window').height;
const DEFAULT_POSITION = {x: 0, y: 0};

export default function BottomDrawer({
  containerHeight,
  midHeight,
  downHeight,
  children,
  offset = 0,
  showShadow = false,
  showHandle = false,
}: Props) {
  const [currentPosition, setCurrentPosition] = useState<Position>({
    x: 0,
    y: 0,
  });
  const toggleThreshold = useRef<number>(50);
  const [upPosition, setUpPosition] = useState<Position>(DEFAULT_POSITION);
  const [midPosition, setMidPosition] = useState<Position>(DEFAULT_POSITION);
  const [downPosition, setDownPosition] = useState<Position>(DEFAULT_POSITION);

  // 계산 담당 effect
  useEffect(() => {
    toggleThreshold.current = Math.round(containerHeight / 11);		// 사용자가 toggleThreshold값 이상 swipe했을 때 애니메이션이 동작한다.
    setUpPosition(calculateUpPosition(SCREEN_HEIGHT, containerHeight, offset));
    setDownPosition(calculateDownPosition(SCREEN_HEIGHT, downHeight));
    setMidPosition(calculateMidPosition(downPosition.y, midHeight, downHeight));
  }, [containerHeight, midHeight, downHeight, offset, downPosition.y]);

  return (
    <Animator
      currentPosition={currentPosition}
      setCurrentPosition={setCurrentPosition}
      toggleThreshold={toggleThreshold.current}
      upPosition={upPosition}
      midPosition={midPosition}
      downPosition={downPosition}
      containerHeight={containerHeight}
      showShadow={showShadow}>
      {showHandle && (
        <View style={styles.handleWrap}>
          <View style={styles.handle} />
        </View>
      )}
      {children}
      <View
        style={{
          height: Math.sqrt(SCREEN_HEIGHT),
        }}
      />
    </Animator>
  );
}

// calculateUpPosition, calculateDownPosition, calculateMidPosition은 number형태의 값을 Animated객체가 사용할 값으로 계산해 하위컴포넌트에 전달한다.
const calculateUpPosition = (
  screenHeight: number,
  containerHeight: number,
  offset: number,
): Position => {
  return {
    x: 0,
    y: screenHeight - (containerHeight + offset),
  };
};
const calculateDownPosition = (
  screenHeight: number,
  downHeight: number,
): Position => {
  return {
    x: 0,
    y: screenHeight - downHeight,
  };
};
const calculateMidPosition = (
  downPositionY: number,
  midHeight: number,
  downHeight: number,
): Position => {
  return {
    x: 0,
    y: Math.round(Math.floor(downPositionY - midHeight + downHeight)),
  };
};

const styles = StyleSheet.create({
	// 생략
});

1-3. Animator.tsx

애니메이션을 담당하는 컴포넌트다.
PanResponder를 이용한다. PanResponder사용법을 어느정도 알아봐야 했다.

PanResponder를 사용할 때 설정옵션 을 잘 살펴보자.
컴포넌트를 제작할 때 옵션을 제대로 살펴보지않아 하위컴포넌트의 클릭이벤트 같은 것들이 동작하지 않았었다.

이 컴포넌트는 크게 두 가지 이벤트에 의해 동작한다.

  • 사용자가 Swipe - 움직이는 중 상태
    다음 위치를 계산한다. 상단, 중단, 하단 중 하나의 위치가 다음 위치가 된다.

  • 사용자가 Swipe - 놓았을 때 상태
    저장된 다음 위치로 애니메이션 한다.

// ...import 생략

const SCREEN_HEIGHT = Dimensions.get('window').height;
const SCREEN_WIDTH = Dimensions.get('window').width;
interface Position {
  x: number;
  y: number;
}
interface Props {
  currentPosition: Position;
  setCurrentPosition: (position: Position) => void;
  toggleThreshold: number;
  upPosition: Position;
  midPosition: Position;
  downPosition: Position;
  showShadow?: boolean;
  containerHeight: number;
  children: ReactNode;
}
export default function Animator({
  currentPosition,
  setCurrentPosition,
  toggleThreshold,
  upPosition,
  midPosition,
  containerHeight,
  downPosition,
  children,
  showShadow = false,
}: Props) {
  // ...생략
  // 1. PanResponderInstance와 
  // 2. 애니메이션 값 객체를 useRef를 이용해 기억시킨다.
  // 3. nextPosition도 Ref를 사용한다.
  // 4. 현재위치도 ref를 사용한다.
 

  const handlePanResponderMove = (
    e: GestureResponderEvent,
    gesture: PanResponderGestureState,
  ) => {
    // ...생략
   // 계산1
   // 사용자가 쓸어올리고 내리는 위치가, 최하단, 최상단을 벗어나면 안되기 때문에 현재위치를
   // 알맞게 계산해준다.
    
    // 계산2
    // gesture.dy, toggleThreshold, currentPosition, downPosition, midPosition, upPosition을 이용해
    // 쓸어내리는 중, 쓸어올리는 중인지 판단하고 
    // 다음위치 (nextPositionRef.current)를 알맞게 변경해준다.
    
  };
  
  
  const handlePanResponderRelease = () => {
    transitionTo(nextPositionRef.current);
  };
  const transitionTo = (next: Position) => {
    // ...생략
    // Animated.spring함수로 애니메이션을 실행하고
    // currentPosition 상태값을 변경한다.
    // next가 객체가 아닌경우에 대해 값 체크도 필요하다
  };

  // ...생략
  // PanResponder.create 함수를 이용해 객체를 생성하고 panResponderRef에 담는다.
  // return부분의 
  // Animated.View에 속성값에 Spread Syntax로 넣어준다.
  
  return (
    <Animated.View
      style={[
        {...position.getLayout()},
        StyleSheet.flatten([
          styles.animationContainer(containerHeight, showShadow),
        ]),
      ]}
      {...panHandlers}>
      {children}
    </Animated.View>
  );
}

// 여기 컴포넌트 바깥에 (바깥에 선언해서 리렌더링시 생성되지 않도록했다.)
// Swipe하는 컴포넌트가 최상단 이상, 최하단 이하로 넘어가는지
// boolean타입을 리턴하는 함수를
// 각각 작성한다.

const styles = 
  // ...생략
  // 속성값으로 받은 showHandle, showShadow에 따라 알맞은 컴포넌트 스타일을 리턴해준다.

참고 라이브러리를 버그수정, 리펙토링 하며 작성했습니다.

profile
웹 프론트엔드, RN앱 개발자입니다.

0개의 댓글