React Native) 커스텀 Animation 컴포넌트 만들기(reanimated 라이브러리 탈출기)

2ast·2023년 12월 20일
1

Reanimated를 제거한 이유

react-native-reanimated는 react native 커뮤니티에서 가장 크고 유명한 애니메이션 라이브러리다. 실제로 좋은 성능과 활발한 커뮤니티, 쉬운 사용법이라는 명확한 장점이 있었기 때문에 지금까지 거의 모든 프로젝트에 인스톨하여 사용하고 있었는데, 최근 다짐에서 이 reanimated 라이브러리를 제거하기로 결정했다.
가장 큰 이유는 reanimated와 관련해 잦은 충돌을 야기하는 이슈가 있었기 때문이다. android에서 유독 높은 빈도로 발생하던 크래시가 있었는데, 추적하다보니 reanimated와 codepush를 함께 사용할 경우 발생하는 케이스였다. 이 문제를 해결하기 위해 커뮤니티 이슈도 트랙킹하며, 수많은 시도를 했지만 결국 크래시를 완벽히 대응하는데 실패했고, 양자택일의 상황에서 codepush를 포기할 수는 없었기에 reanimated를 제거하기로 결정했다. 비슷한 시기에 성능상의 이슈로 잠깐 hermes에서 JSC로 컴파일러를 변경했던 적이 있는데, 이때 reanimated와 JSC가 충돌하며 일부 ios 기기에서 충돌이 발생했던 것도 주요하게 작용했다.
결론적으로, reanimated가 다른 라이브러리, 또는 컴파일러와 로우한 레벨에서 예상치 못하게 충돌을 일으키는 케이스가 많이 발견되었고, 장기적으로 보아도 이런 불안요소를 안고가는 것은 바람직하지 못하다고 판단했다. 또한, 현재 다짐에서 사용하고 있는 애니메이션이 많지 않고, react-native에서 제공하는 기본 Animated 컴포넌트만으로 충분히 대응이 가능하다는 점도 의사결정에 큰 영향을 줬다.

Transition Component

exiting animation을 위해 unmount 지연시키기

기존 react-native-reanimated를 사용했을 때는, AnimatedComponent 자체에 직접 props로 entering, exiting animation을 지정할 수 있었다.

<Animated.View entering={FadeIn} exiting={FadeOut}/>

덕분에 적은 코드로, 조건부 렌더링을 걸어도 자연스럽게 exiting 애니메이션을 실행할 수 있었지만, reanimated를 제거한 이상 직접 구현해야했다. 이를 위해 가장 먼저 한 일은, visible state가 false로 바뀌었을 때 exiting animation이 실행될 수 있도록, 컴포넌트의 unmount를 지연시키는 작업이었다.


export const useFadeTransition = ({
  entering,
  exiting,
  hide,
  show,
  duration
}: DgTransitionHookParams) => {
  // fade animation value (opacity)
  const fadeAnim = useRef(new Animated.Value(0)).current;

  const enter = () => {
    const animation = Animated.timing(fadeAnim, {
      toValue: 1,
      duration,
      useNativeDriver: true,
    });

    // component를 즉시 보여주고, 애니메이션도 즉시 시작한다.
    show();
    animation.start();
  };

  const exit = () => {
    const animation = Animated.timing(fadeAnim, {
      toValue: 0,
      duration,
      useNativeDriver: true,
    });


    // 애니메이션을 모두 마친 후, 컴포넌트를 unmount한다.
    animation.start(() => {
      hide();
    });
  };

  return {
    fadeAnim,
    enter,
    exit,
  };
};
const Transition = ({
  isVisible,
  children,
  entering,
  exiting,
  duration,
  style
}: DgTransitionProps) => {
  // Transition 외부에서 주입받는 isVisible을 기반으로 새로운 visible state를 생성한다.
  const [childrenVisible, setChildrenVisible] = useState(isVisible);
  
  // 첫번째 렌더인지 판단하기 위한 값. 내부 구현은 https://usehooks-ts.com/react-hook/use-is-first-render 참조.
  const isFirstRender = useIsFirstRender();

  const {fadeAnim, enter, exit} = useFadeTransition({
    entering,
    exiting,
    hide: () => setChildrenVisible(false),
    show: () => setChildrenVisible(true),
    duration
  });

  // 이전 visible state 값을 저장하는 state도 별도로 선언해둔다.
  const [prevIsVisibleProp, setPrevIsVisibleProp] = useState(isVisible);

  
  // 만약 외부의 isVisible과 현재 저장되어 있는 visible값이 다르다면 isVisible값에 따라 enter 또는 exit animation을 실행한다.
  // 만약 초기 isVisible이 true인 경우 entering animation과 함께 mount되어야하므로, isFirstRender를 조건문에 추가한다.
  if (isFirstRender || isVisible !== prevIsVisibleProp) {
    isVisible ? enter() : exit();
    setPrevIsVisibleProp(isVisible);
  }

  return (
    <Animated.View
      style={[
        {
          display: childrenVisible ? 'flex' : 'none',
          opacity: fadeAnim,
        },
        style,
      ]}>
      {children}
    </Animated.View>
  );
};

Transition에 넘겨지는 isVisible을 그대로 component rendering에 반영하지 않고, childrenVisible이라는 별개의 state를 선언하여, 사용하고 있다. 만약 isVisible이 바뀌면, 값에 따라 enter, 또는 exit 함수가 실행되는데, exit 함수의 정의를 보면, 주어진 duration 길이만큼 animation을 실행한 뒤에야 hide를 호출하는 것을 볼 수 있다. 이로써 컴포넌트의 unmount를 지연하여 exiting animation이 보여질 수 있게 되었다.
여담으로, 위 코드에서 prevIsVisibleProp state를 선언하여 isVisible과 값을 비교하고 있는데, 이처럼 prevState를 선언해서 값을 비교하는 로직을 컴포넌트에 직접 배치하면, 불필요한 useEffect를 방지할 수 있다.

useEffect(()=>{
  isVisible ? enter() : exit();
},[isVisible])

실제로 동작하는 코드 자체는 위처럼 useEffect를 쓰는것과 동일한 효과를 내지만, useEffect는 말 그대로, 정상적인 react의 flow를 벗어나 effect를 발생시키는 용도이므로, 최대한 지양하는 것이 좋다. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)

중복 애니메이션 제거

만약 entering animation이 실행중일 때 exit 함수가 호출되거나, exiting animation이 실행중일 때 enter 함수가 호출되면 상태가 꼬일 수 있다. 이런 문제를 미연에 방지하고자, useFadeTransition의 로직에, 실행중인 animation이 있다면 animation을 중단하는 코드를 추가했다.


export const useFadeTransition = ({
  entering,
  exiting,
  hide,
  show,
  duration
}: DgTransitionHookParams) => {
  const fadeAnim = useRef(new Animated.Value(0)).current;

  // 현재 실행중인 animation을 저장하고 있다.
  const currentAnimation = useRef<Animated.CompositeAnimation | null>(
    null,
  );


  const enter = () => {
    // 실행중인 animation이 있다면 stop
    currentAnimation.current?.stop();
    
    const animation = Animated.timing(fadeAnim, {
      toValue: 1,
      duration,
      useNativeDriver: true,
    });

    show();

    // 현재 실행중인 animation을 currentAnimation에 할당한다.
    currentAnimation.current = animation;

    // animation이 끝나면 currentAnimation을 초기화 해준다.
    animation.start(() => (currentAnimation.current = null));
  };

  const exit = () => {
    // 실행중인 animation이 있다면 stop
    currentAnimation.current?.stop();
    
    const animation = Animated.timing(fadeAnim, {
      toValue: 0,
      duration,
      useNativeDriver: true,
    });

    // 현재 실행중인 animation을 currentAnimation에 할당한다.
    currentAnimation.current = animation;

    // animation이 끝나면 currentAnimation을 초기화 해준다.
    animation.start(() => {
      hide();
      currentAnimation.current = null;
    });
  };

  return {
    fadeAnim,
    enter,
    exit,
  };
};

slide transition과 공존하기

export type DgTransitionType =
  | 'fade'
  | 'slide-up'
  | 'slide-down'
  | 'fade-slide-up'
  | 'fade-slide-down';

다짐에서 사용하는 애니메이션은 fade와 slide의 조합으로 구성되어 있다. Transition이라는 하나의 컴포넌트로, 이 애니메이션을 모두 사용할 수 있도록 구성했기 때문에, slide transition일 때는 fade transition 관련 코드가 동작하면 안되었다. 이 요구조건을 충족하기 위해서, useFadeTransition hook 내부에 관련 코드를 추가했다.

const isFadeTransition = (animation?: DgTransitionType) => {
  return (
    animation === 'fade' ||
    animation === 'fade-slide-up' ||
    animation === 'fade-slide-down'
  );
};


export const useFadeTransition = ({
  entering,
  exiting,
  hide,
  show,
  duration,
}: DgTransitionHookParams) => {
  const fadeAnim = useRef(new Animated.Value(0)).current;

  const currentAnimation = useRef<Animated.CompositeAnimation | null>(null);

  // entering에 fade가 적용되는지 판단
  const isFadeEntering = isFadeTransition(entering);
  // exiting에 fade가 적용되는지 판단
  const isFadeExiting = isFadeTransition(exiting);

  const enterFade = () => {
    currentAnimation.current?.stop();

    const animation = Animated.timing(fadeAnim, {
      toValue: 1,
      duration,
      useNativeDriver: true,
    });

    show();

    currentAnimation.current = animation;

    if (isFadeEntering) {
      animation.start(() => (currentAnimation.current = null));
    } else {
      // 만약 fade entering이 아니라면, animation을 실행하지 않고, 즉시 fadeAnim을 target value로 셋팅
      currentAnimation.current = null;
      fadeAnim.setValue(1);
    }
  };

  const exitFade = () => {
    currentAnimation.current?.stop();
    const animation = Animated.timing(fadeAnim, {
      toValue: 0,
      duration,
      useNativeDriver: true,
    });

    currentAnimation.current = animation;

    if (isFadeExiting) {
      animation.start(() => {
        hide();
        currentAnimation.current = null;
      });
    } else {
      // 만약 fade exiting이 아니라면, animation을 실행하지 않고 즉시 fadeAnim을 target value로 셋팅
      fadeAnim.setValue(0);
      currentAnimation.current = null;
      // slide animation이 동작하고 있을 수 있으니, duration뒤에 hide를 호출한다.
      setTimeout(hide, duration);
    }
  };

  return {
    // fade animation이라면 fadeAnim을, 그렇지 않으면 항상 1을 반환한다.
    fadeAnim: isFadeEntering || isFadeExiting ? fadeAnim : 1,
    enterFade,
    exitFade,
  };
};

최종 결과물

위에서 작성한 useFadeTransition과 동일한 컨셉으로 useSlideTransition을 작성했고, 이를 useDgTransition이라는 custom hook으로 묶어서 사용했다. 여기에 animation 자유도를 위해 몇가지 props를 추가한 최종 모습은 다음과 같다.

Transtion

export type DgTransitionType =
  | 'fade'
  | 'slide-up'
  | 'slide-down'
  | 'fade-slide-up'
  | 'fade-slide-down';

interface DgTransitionProps {
  isVisible: boolean;
  children: React.ReactNode;
  style?: StyleProp<ViewStyle>;
  duration?: number;
  enteringDuration?: number;
  exitingDuration?: number;
  entering?: DgTransitionType;
  exiting?: DgTransitionType;
  enteringDelay?: number;
  exitingDelay?: number;
  slideOffset?: number;
}

const Transition = ({
  isVisible,
  children,
  entering,
  exiting,
  duration,
  enteringDuration = entering ? duration || 300 : 0,
  exitingDuration = exiting ? duration || 300 : 0,
  style,
  enteringDelay = 0,
  exitingDelay = 0,
  slideOffset,
}: DgTransitionProps) => {
  const [childrenVisible, setChildrenVisible] = useState(isVisible);
  const isFirstRender = useIsFirstRender();

  const {fadeAnim, slideStyle, enter, exit} = useDgTransition({
    entering,
    exiting,
    hide: () => setChildrenVisible(false),
    show: () => setChildrenVisible(true),
    enteringDuration,
    exitingDuration,
    enteringDelay,
    exitingDelay,
    slideOffset,
  });

  const [prevIsVisibleProp, setPrevIsVisibleProp] = useState(isVisible);

  if (isFirstRender || isVisible !== prevIsVisibleProp) {
    isVisible ? enter() : exit();

    setPrevIsVisibleProp(isVisible);
  }

  return (
    <Animated.View
      style={[
        {
          display: childrenVisible ? 'flex' : 'none',
          opacity: fadeAnim,
        },
        slideStyle,
        style,
      ]}>
      {children}
    </Animated.View>
  );
};
export default Transition;

useDgTransition

export interface DgTransitionHookParams {
  entering?: DgTransitionType;
  exiting?: DgTransitionType;
  enteringDuration?: number;
  exitingDuration?: number;
  hide: () => void;
  show: () => void;
  enteringDelay: number;
  exitingDelay: number;
  slideOffset?: number;
}

export const useDgTransition = (params: DgTransitionHookParams) => {
  const {fadeAnim, enterFade, exitFade} = useFadeTransition(params);
  const {slideStyle, enterSlide, exitSlide} = useSlideTransition(params);
  const enter = () => {
    setTimeout(() => {
      enterFade();
      enterSlide();
    }, params.enteringDelay);
  };
  const exit = () => {
    setTimeout(() => {
      exitFade();
      exitSlide();
    }, params.exitingDelay);
  };

  return {fadeAnim, slideStyle, enter, exit};
};

useFadeTransition


const isFadeTransition = (animation?: DgTransitionType) => {
  return (
    animation === 'fade' ||
    animation === 'fade-slide-up' ||
    animation === 'fade-slide-down'
  );
};

export const useFadeTransition = ({
  enteringDuration,
  exitingDuration,
  entering,
  exiting,
  hide,
  show,
}: DgTransitionHookParams) => {
  const fadeAnim = useRef(new Animated.Value(0)).current;

  const currentAnimation = useRef<Animated.CompositeAnimation | null>(null);

  const isFadeEntering = isFadeTransition(entering);
  const isFadeExiting = isFadeTransition(exiting);

  const enterFade = () => {
    currentAnimation.current?.stop();

    const animation = Animated.timing(fadeAnim, {
      toValue: 1,
      duration: enteringDuration,
      useNativeDriver: true,
    });

    show();

    currentAnimation.current = animation;

    if (isFadeEntering) {
      animation.start(() => (currentAnimation.current = null));
    } else {
      currentAnimation.current = null;
      fadeAnim.setValue(1);
    }
  };

  const exitFade = () => {
    currentAnimation.current?.stop();
    const animation = Animated.timing(fadeAnim, {
      toValue: 0,
      duration: exitingDuration,
      useNativeDriver: true,
    });

    currentAnimation.current = animation;

    if (isFadeExiting) {
      animation.start(() => {
        hide();
        currentAnimation.current = null;
      });
    } else {
      fadeAnim.setValue(0);
      currentAnimation.current = null;
      setTimeout(hide, exitingDuration);
    }
  };

  return {
    fadeAnim: isFadeEntering || isFadeExiting ? fadeAnim : 1,
    enterFade,
    exitFade,
  };
};

useSlideTransition

const isSlideDownTransition = (animation?: DgTransitionType) => {
  return animation === 'slide-down' || animation === 'fade-slide-down';
};

const isSlideUpTransition = (animation?: DgTransitionType) => {
  return animation === 'slide-up' || animation === 'fade-slide-up';
};

const isSlideTransition = (animation?: DgTransitionType) => {
  return isSlideDownTransition(animation) || isSlideUpTransition(animation);
};

export const useSlideTransition = ({
  enteringDuration,
  exitingDuration,
  entering,
  exiting,
  hide,
  show,
  slideOffset = 1000,
}: DgTransitionHookParams) => {
  const positionY = React.useRef(new Animated.Value(0)).current;

  const isSlideEntering = isSlideTransition(entering);
  const isSlideExiting = isSlideTransition(exiting);

  const currentAnimation = React.useRef<Animated.CompositeAnimation | null>(
    null,
  );

  const enterSlide = () => {
    currentAnimation.current?.stop();
    const animation = Animated.timing(positionY, {
      toValue: 1,
      duration: enteringDuration,
      useNativeDriver: true,
    });

    show();

    currentAnimation.current = animation;

    if (isSlideEntering) {
      animation.start(() => (currentAnimation.current = null));
    } else {
      currentAnimation.current = null;
      positionY.setValue(1);
    }
  };

  const exitSlide = () => {
    currentAnimation.current?.stop();
    const animation = Animated.timing(positionY, {
      toValue: 0,
      duration: exitingDuration,
      useNativeDriver: true,
    });

    currentAnimation.current = animation;

    if (isSlideExiting) {
      animation.start(() => {
        hide();
        currentAnimation.current = null;
      });
    } else {
      positionY.setValue(0);
      currentAnimation.current = null;
      setTimeout(hide, exitingDuration);
    }
  };

  const interpolateY = positionY.interpolate({
    inputRange: [0, 1],
    outputRange: [
      isSlideDownTransition(entering) || isSlideDownTransition(exiting)
        ? slideOffset
        : -slideOffset,
      0,
    ],
    extrapolate: 'clamp',
  });

  const slideStyle = {
    transform: [
      {
        translateY: isSlideEntering || isSlideExiting ? interpolateY : 0,
      },
    ],
  };

  return {
    slideStyle,
    enterSlide,
    exitSlide,
  };
};

Troubleshooting

위 영상에서도 등장하듯이, 다짐에서는 바텀시트를 직접 커스텀해서 사용하고 있다. 이때 바텀시트의 배경이 점점 밝아지는 fade animation과 시트가 아래에서 올라오는 slide 애니메이션 모두에 직접 만든 Transition component를 적용했는데, android 특정 기기에서 바텀시트내 ScrollView 스크롤이 안되는 이슈가 발생했다.

<Transition
  isVisible={isShown}
  entering={'slide-down'}
  exiting={'slide-down'}
  style={{
    position: 'absolute',
    left: 0,
    bottom: 0,
  }}
  duration={300}>
  <BottomSheetContainer
    screenWidth={SCREEN_WIDTH}
    screenHeight={SCREEN_HEIGHT}>
    {children}
  </BottomSheetContainer>
</Transition>

문제가 되는 부분은 위에 보이는 코드였는데, 오랜 삽질 끝에 style에 width와 height를 지정해주지 않았기 때문임을 알게 되었다.

// component layout 정보를 쉽게 가져올 수 있게 도와주는 hook 
// https://github.com/react-native-community/hooks/blob/main/src/useLayout.ts 참조
const {height, onLayout} = useLayout();

return (
  <Transition
    isVisible={isShown}
    entering={'slide-down'}
    exiting={'slide-down'}
    style={{
      position: 'absolute',
      left: 0,
      bottom: 0,
      width: '100%',
      height,
    }}
    duration={300}>
    <BottomSheetContainer
      onLayout={onLayout}
      screenWidth={SCREEN_WIDTH}
      screenHeight={SCREEN_HEIGHT}>
      {children}
    </BottomSheetContainer>
  </Transition>
)

위와같이 Animated.View에 bottom sheet와 동일한 width, height를 주자 정상 동작했다. 일부 android 기기에서만 내부 scroll이 동작하지 않는 이슈이기도 하고, 사실 width, height는 auto로 맞춰져야하는게 맞기 때문에(스크롤을 제외한 뷰나 터치 등은 정상적으로 동작했다.) 여전히 이건 버그였다는 생각이 든다.

Todo

이렇게 해서 Transition 컴포넌트를 만들었고, 성공적으로 react-native-reanimated를 프로젝트에서 삭제할 수 있었다. 꽤나 만족스러운 작업이었지만 여전히 조금 아쉬운 부분들이 있다. 가장 대표적으로는 props가 너무 장황하다는 점이 있다.

interface DgTransitionProps {
  isVisible: boolean;
  children: React.ReactNode;
  style?: StyleProp<ViewStyle>;
  duration?: number;
  enteringDuration?: number;
  exitingDuration?: number;
  entering?: DgTransitionType;
  exiting?: DgTransitionType;
  enteringDelay?: number;
  exitingDelay?: number;
  slideOffset?: number;
}

위와같이 animation type과 duration, delay, offset 등을 각각 개별 props로 받고 있으며, entering과 exiting을 또 각각 넘겨받고 있다. 하지만 react-native-reanimated를 기억해보면 훨씬 간단하게 값을 넘기고 있었던 것을 알 수 있다.

<Animated.View entering={FadeIn.duration(300).delay(100)} exiting={FadeOut.duration(500).delay(100)}/>

이 형태가 훨씬 보기도 좋고, 사용성도 좋다는 생각이 들기 때문에, 다음에는 시간을 내서 체이닝으로 애니메이션 정보를 넘기는 작업을 해보려고 한다.

끗.

profile
React-Native 개발블로그

0개의 댓글