기록의 습관화
article thumbnail

이번 포스트에서는 React Native Webview에서 페이지 전환 애니메이션에 대해서 다뤄보려고 합니다.

처음에 검색해서 얕봤다가 되게 고생한 기능 중에 하나네요 😂

 

웹 보다 사용자 경험이 좋다고 느껴지는 앱에서 웹뷰를 사용하게 되는 이유는 당연하게도 배포에 대한 부담이 적기 때문이라고 볼 수 있습니다. 앱의 문제가 생기거나 A, B 테스트를 해야 하는 상황에서 사람이 직접 검수하는 스토어 심사를 기다리면 너무나도 비효율 적이기 때문에 많이 채택이 되는 이유라고 볼 수 있습니다.

 

그러나 이 웹뷰를 통해서 앱을 개발했을 때 중요한 포인트 중 하나가 

과연 Native 스러운가?를 빼놓을 수 없습니다.

좋은 앱을 만들어두고 사용자 경험을 해치는 걸 원하는 client 개발자는 없겠죠.

그럼 이번에 어떤 시도들을 해보았나? 그리고 어떻게 만들었나에 대해서 적어 보려고 합니다.

 

 

Stack 효과는 무엇 인가?

시도한 방법들에 들어가기 앞서서 Stack Animation 효과에 대해서 간단하게 설명을 해볼까 합니다.

말 그대로 Stack처럼 화면에 계속 카드처럼 쌓게 되는 것인데 그림으로 표현해 보자면 다음과 같습니다.

 

1. 페이지가 새 페이지를 방문하는 경우

- 아래의 그림과 같이 새로운 페이지가 오른쪽에서 밀면서 들어오게 됩니다.

새 페이지 방문

2. 페이지를 나가는 경우
- 아래의 그림과 같이 새로운 페이지는 왼쪽에서 들어오게 되고 
- 나가는 페이지를 오른쪽으로 이동하게 됩니다.

페이지를 이탈 할 경우

시도 1. 여러 개의 Webview를 사용하여 StackNavigation 효과 주기

 

처음에 기능을 구현하고자 했을 때 가장 먼저 시도한 방법 중 하나입니다.

React Native에서는 @react-navigation/stack라는 라이브러리를 사용하여서 Native와 같은 내비게이션 효과를 구현할 수 있습니다.

하지만 한 가지 문제점이 있는데 우리가 사용하려는 webview의 라이브러리에서는 이를 보통의 방법으로는 사용하기 어렵습니다.

 

이유는 기존의 React Native에서는 router를 이용해서 자체적인 React Native 페이지마다

Stack.Screen으로 감싸서 효과를 줄 수 있지만

Webview 자체에서는 사용자 클릭에 따른 페이지가 이동되는 것을 React Native 내부적으로 페이지로 분리할 수 없기 때문입니다.

 

그래도 해결 방법은 있습니다.

 

  1. 방문하는 url 정보에 대해서 저장
  2. url들에 대해서 각각 Stack.Screen으로 감싼 컴포넌트를 생성

이렇게 하면 페이지 전환 애니메이션 효과를 Native와 동일하게 줄 수 있습니다.

 

코드로 간단하게 볼까요?

 

더보기
// Stack.Screen 만들어주기
export default function App() {
	const { urls } = webviewStore(); // 웹뷰 url을 관리하는 store

	return (
		<NavigationContainer ref={navigationRef}>
			<Stack.Navigator>
				<Stack.Screen name='Home' component={HomeScreen} />
				{urls.map((_, index) => (
					<Stack.Screen
						key={index}
						name={`Webview${index}`}
						component={Webview}
					/>
				))}
			</Stack.Navigator>
		</NavigationContainer>
	);
}

// 실제 사용하는 웹뷰
const Webview = () => {
	const { urls, setUrl } = webviewStore();

	const handleNavigationStateChange = async (navState: WebViewNavigation) => {
		const url = navState.url;

		setUrl(url);
		if (navigationRef.current?.isReady()) {
			navigationRef.current?.navigate(`Webview${urls.length - 1}`);
		}
	};

	return (
		<WebView
			source={{
				uri: urls[urls.length - 1]
			}}
			onNavigationStateChange={handleNavigationStateChange}
		/>
	);
};

export default Webview;

이 방식의 핵심은

onNavigationStateChange

를 통해서 사용자의 webview url이 바뀜을 체크하고 url이 바뀔 때마다 새로운 Stack.Screen을 만들어주는 것입니다.

전역 레벨로 url을 관리하기 위해서 Context.API 또는 전역 상태 라이브러리 중 입맛에 맞으시는 걸 고르셔도 무방할 것 같습니다.

 

하지만 이 방식에도 신경 써줘야 할 점과 가장 중요하게 expo router 기반 React Native 프로젝트에서는 이를 사용하기 어렵습니다.

 

  1. React Query 캐싱 동기화 필요 
    각각의 다른 Webview 컴포넌트를 가지기 때문에 자체적으로 사용하던 React Query 캐싱의 동기화 문제를 해결해 줘야 합니다.
  2. 사용자가 새로운 페이지 방문 시 웹에서는 페이지 이동을 막고 bridge 방식을 통해 다음 url을 전달해 줘야 함
    이 예시에서는 간단하게 onNavigationStateChange를 사용하였는데 이렇게 사용하시면 페이지가 이동된 뒤에 똑같은 창이 오른쪽에서 나오게 되는 걸 보시게 될 겁니다. 🥲
  3. webview 로딩 시간 해결
    webview 컴포넌트를 새로 띄우게 되면 적어도 0.2ms 이상의 delay가 존재하여 사용자는 흰 화면을 바라보게 됩니다. Stack 페이지들을 아래로 깔아 두고 사용할 때 위로 올리는 방식으로 해결하거나 다른 방식을 통해 이 딜레이 시간에 대한 처리가 필요합니다.

제가 이 방식을 포기한 이유 중 하나는 expo router에서는 이렇게 사용하기 어렵다는 점입니다.

 

expo router는 react navigation을 좀 더 추상화시킨 자체적인 navigation을 사용하는데

페이지 단위의 컴포넌트들을 선언하는 방식이 좀 다릅니다.

 

<Stack.Screen
  name="login"
  options={{
    animation: 'flip',
  }}
/>

보시는 바와 같이 어떤 컴포넌트를 렌더링 할지에 대해서 next와 비슷한 방식으로 라우팅 구조를 가져가는데

제가 시도했을 때는 name에 대한 dynamic routing이 동적으로 생성되는 webview 페이지들에 대해서는 죽어도 동작하지 않고 오류를 뱉어서 이 방식은 사용하는데 포기를 했습니다.

 

시도 2. StackFlow 라이브러리 사용하기

StackFlow는 당근 개발자 분께서 만드신 webview를 위한 routing 라이브러리라고 할 수 있습니다.

devtools도 지원하고 개발자가 플러그인도 손쉽게 작성하여 추가할 수 있는 좋은 라이브러리입니다.

(무려 공식문서가 한글 지원!)

 

https://stackflow.so/

 

Stackflow 시작하기 - Stackflow

 

stackflow.so

 

만약 이 StackFlow를 사용하게 된다면 주의할 점이 있습니다.

 

  1. router를 바꾸는 것 이기 때문에 페이지가 많을수록 변경사항이 많아짐.
    기존에 사용하셨던 router를 아예 새로운 router로 교체를 하는 것 이기 때문에
    페이지의 선언 방식과 router param으로 넘기는 코드들이 아예 바뀌게 됩니다.
    바꾸는 데는 고통이 좀 있었지만 type safe 하게 가져갈 수 있어서 개발경험은 좀 더 좋았던 거 같네요.
    도입을 결정한다면 프로젝트 초기에 사용하는 걸 추천드립니다. 
  2. Frame Drop issue
    변경 사항들을 모두 되돌린 이유 중 하나입니다 😭
    webview의 브라우저 페이지들은 다른 애니메이션 전환 효과들은 괜찮은데 transition에 대해서는 유독 프레임 드랍이 눈에 띄게 거슬리는 경우가 있습니다.
    특히 BottomSheet를 사용하거나 이번 StackFlow를 사용했을 때도 요즘 핸드폰에 지원하는 120 fps를 매끄럽게 전환하지 못하는 이슈가 있었습니다. 사용자 경험을 좋게 하기 위해서 애니메이션 전환 효과를 넣으려고 했기 때문에 눈물을 머금고 다른 방법을 찾아보기로 했습니다.
    이전에 쿠팡 앱에서도 상품 페이지에서 유독 프레임 드랍이 심하게 일어나던데 요즘은 고쳐진 듯합니다.

무려 54개의 변경사항

시도 3. Animated.View를 통해 직접 구현하기

위의 두 방식에 좌절을 하고 React Native 쪽으로 돌아와 React Native에서 지원하는 Animated View를 사용해 보면 어떨까? 해서 시도를 해보았습니다.

여기도 여러 가지 시도를 해보았습니다.

 

  1. 두 개의 Webview를 Animated.View로 Swtich 하기
  2. 이동 전 페이지를 캡처하여 Background에 배치하기
  3. React Native Reanimated 사용해 보기

 

 

방식은 Animated.View를 사용한다는 점에서 동일하니 간단하게만 어떤 시도를 했고 어떤 문제점이 있었는지에 대해서 적어보겠습니다.

 

시도 3-1. 두 개의 Webview를 Animated.View로 Switch 하기

이 방식도 시도 1과 비슷한 이유에서 도입을 포기했습니다.

 

  1. 웹뷰 로딩 타이밍 이슈
  2. React Query cache 동기화 문제
  3. 사용자의 웹 새로운 페이지 방문 시 방문 페이지 Link, a 방식 변경 및 방문 페이지 Bridge로 전달 필요

Mvp 개발에서의 시간과 해결해야 할 이슈들의 난이도가 좀 있어 보였기에 시도만 해보고 다음에 다시 도전해 보기로 하였습니다.

 

시도 3-2. 이동 전 페이지를 캡처하여 Background에 배치하기

Webview 페이지를 매번 새로 띄우는 것에 부담감이 있기 때문에 다른 방법을 생각해 보았습니다.

그러다가 그냥 Background에 띄워질 페이지는 중요한 페이지가 아니니 캡처를 해버리면 어떨까? 해서 이 방식을 도입해 보았습니다.

 

방식은 다음과 같습니다. (새 페이지 방문 기준)

 

  1. 웹뷰에서 페이지 url 변경 시 페이지 스크린숏
  2. 스크린숏 된 페이지를 뒤에다 배치하고 Webivew에 translateX 효과 적용

하지만 이 방식에도 약간의 문제는 존재합니다.

 

  1. 캡처는 비동기 작업이기 때문에 delay가 존재함
    이번 캡처는 react-native-view-shot 라이브러리를 사용했는데 캡처에는 delay 시간이 존재할 수밖에 없습니다.
    이로 인해서 사용자는 Background에 깔리는 이미지가 좀 늦게 뜨는 현상을 바라보게 되기에
    이를 이미지에 Opacity 또한 Animated를 걸어서 duration 동안 천천히 밝아지도록 했습니다.
  2. 여전한 Frame Drop issue 
    Stack Flow를 사용할 때보다는 덜 하지만 여기서도 동일하게 프레임 드랍 이슈가 발생했습니다.
    가장 이상한 점이 duration 시간을 늘리면 실제 핸드폰에서 동작을 시켰을 때 120 프레임 방어가 되는 듯하는데
    이게 시간을 줄이면 줄일수록 뻣뻣해지는 느낌이 많이 났습니다.

    가장 난해 했던 점이 시뮬레이터 상으로는 최대 60 프레임만 지원을 해줘서 이게 실제로 120 프레임이 되는 건지에 대한 확인을 해볼 수 없다는 점이었습니다. 😂

UI 60 프레임 고정

코드로 보여드리면 다음과 같습니다.

 

더보기
export type MODE = 'SIGN_IN' | 'SIGN_UP';
const clientUrl = Constants.expoConfig?.extra?.clientUrl || '';
const EXPLICIT_URL = [
  `${clientUrl}/`,
  `${clientUrl}/friend`,
  `${clientUrl}/notification`,
  `${clientUrl}/profile`,
];

const webviewURL: Record<MODE, (id?: number) => string> = {
  SIGN_IN: () => '',
  SIGN_UP: (id?: number) => `/user/${id}`,
};

const windowWidth = Dimensions.get('window').width;

const App = () => {
  const [snapshotUri, setSnapshotUri] = useState<string>('');
  const [loading, setLoading] = useState(true);
  const [isGoingBack, setIsGoingBack] = useState(false); // 뒤로 가기 상태 관리

  const animationValue = useRef(new Animated.Value(windowWidth)).current;
  const snapShotAnimationValue = useRef(new Animated.Value(0)).current;
  const imageOpacity = useRef(new Animated.Value(0.8)).current;
  const [isInitialized, setIsInitialized] = useState(false);

  const { user } = useAuthContext();
  const [mode, setMode] = useState<MODE>('SIGN_UP');

  const handleNavigationStateChange = async (navState: WebViewNavigation) => {
    const url = navState.url;
    if (!isGoingBack) captureScreenFn(setSnapshotUri);
    if (EXPLICIT_URL.includes(url) && !isGoingBack) {
      if (isInitialized) return;
      Animated.timing(animationValue, {
        toValue: 0,
        duration: 300,
        useNativeDriver: true,
        easing: Easing.sin,
      }).start(() => {
        setIsGoingBack(false);
        setIsInitialized(true);
      });
    }

    if (url) {
      snapShotAnimationValue.setValue(0);
      animationValue.setValue(isGoingBack ? -windowWidth : windowWidth / 4);

      Animated.timing(animationValue, {
        toValue: 0,
        duration: 300,
        useNativeDriver: true,
        easing: Easing.sin,
      }).start(() => {
        setIsGoingBack(false);
      });

      webviewBridge(webviewRef, 'initialize', null)();
    }
  };

  // 뒤로 가기 상태가 변경되면 애니메이션 업데이트
  useEffect(() => {
    snapShotAnimationValue.setValue(0);

    Animated.timing(snapShotAnimationValue, {
      toValue: windowWidth,
      duration: 300,
      useNativeDriver: true,
      easing: Easing.sin,
    }).start(() => {
      setIsGoingBack(false);
    });
  }, [isGoingBack]);


  const onWebViewMessage = (event: WebViewMessageEvent) => {
    if (event.nativeEvent.data == 'goBack') {
      captureScreenFn(setSnapshotUri);
      setIsGoingBack(true);
    }
  };


  return (
    <>
      {!!loading && <Loading />}
      <View style={styles.container}>
        {/* 스냅샷 */}
        {snapshotUri && (
          <Animated.View
            style={[
              styles.container,
              {
                opacity: imageOpacity,
                transform: [
                  {
                    translateX: snapShotAnimationValue,
                  },
                ],
              },
            ]}
          >
            <Image
              onLoad={() => {
                Animated.timing(imageOpacity, {
                  toValue: 1,
                  duration: 400,
                  useNativeDriver: true,
                  easing: Easing.sin,
                }).start(() => {
                  imageOpacity.setValue(0);
                });
              }}
              style={styles.fullImage}
              source={{ uri: snapshotUri }}
            />
          </Animated.View>
        )}

        {/* 기존 WebView */}
        <Animated.View
          style={[
            styles.container,
            {
              transform: [
                {
                  translateX: animationValue,
                },
              ],
            },
          ]}
        >
          <SafeAreaView style={styles.safeArea}>
            <WebView
              ref={webviewRef}
              source={{
                uri: `${
                  Constants.expoConfig?.extra?.clientUrl || ''
                }${webviewURL[mode](userInfo?.id)}`,
              }}
              onMessage={onWebViewMessage}
              onNavigationStateChange={handleNavigationStateChange}
              allowsBackForwardNavigationGestures={true}
            />
          </SafeAreaView>
        </Animated.View>
      </View>
    </>
  );
};

const styles = StyleSheet.create({
  safeArea: {
    flex: 1,
    backgroundColor: Colors.dark.background,
  },
  container: {
    flex: 1,
    backgroundColor: Colors.dark.background,
    position: 'absolute',
    width: '100%',
    height: '100%',
    zIndex: 1,
    top: 0,
    left: 0,
  },
  fullImage: {
    zIndex: 0,
    position: 'absolute',
    height: '100%',
    width: '100%',
    top: 0,
    left: 0,
  },
});

export default App;

const captureScreenFn = async (
  setSnapShotUri: Dispatch<SetStateAction<string>>,
) => {
  captureScreen({
    format: 'png',
    quality: 0.8,
    fileName: 'screenshot',
  }).then((uri) => setSnapShotUri(uri));
};

 

시도 3-3. React Native Reanimated 사용해 보기

하다 하다가 너무 답답해서 React Native Navigation Stack 코드를 뜯어보았습니다.

 

node_modules 안에서 이를 코드로 들여다보게 되면

Stack Navigation은 자체적은 @react-navigationreact-native-screens를 사용하여 이를 구현하게 됩니다.

여기서는 React Native Reanimated를 추상화 한 ReanimatedScreen을 사용하고

자체적인 NativeScreen을 native에게 할당받아 사용하는 것으로 보였습니다.

 

https://docs.swmansion.com/react-native-reanimated/

 

Hello from React Native Reanimated | React Native Reanimated

Description will go into a meta tag in <head />

docs.swmansion.com

 

여기서 조금 힌트를 얻어서 지금 직접 Native 코드까지 만들어내는 것은 무리가 있을지 몰라도 React Native Reanimated를 사용해 보면 어떨까? 해서 도전을 해보았습니다.

 

왜 Stack Navigation에서 React Native Navigation Stack를 사용하나 궁금해서 좀 찾아보았더니 장점이 좀 명확했습니다.

 

  1. UI 스레드에서 직접 애니메이션을 실행시키기 때문에 JavaScript 스레드와 통신 없이 애니메이션을 처리함으로써 성능을 크게 향상 시킴
    복잡한 애니메이션 효과를 구현할 때 기본 Animated.View 보다 프레임 드랍이 덜 생김
  2. 제스처를 좀 더 쉽게 구현
    패닝이나 사용자 동작을 구현할 때 사용하는 React Native Gesture Handler와 통합으로 Reanimated는 사용자 제스처에 반응하고 복잡한 애니메이션을 좀 더 쉽게 구현 가능

근데 실제로 써본 결과 유의미한 성능 개선이 이루어지지 않았습니다.

이미 기존의 useNativeDriver를 사용해서 UI 스레드 내에서 애니메이션 전환이 이루어졌고

프레임 드랍 이슈는 해결되지 않았습니다.

 

그래서 결국은...?

 

일단 MVP 개발을 빠르게 올려야 하는 점에 있어서 시도 1로 돌아가서 expo router를 걷어냈을 때의 사이드 이펙트와 해결해야 하는 이슈들이 있었기에 easing을 통해서 이를 개선시켰습니다.

완벽하게 120 프레임을 방어해주지는 못했지만 그래도 이전에 처음 시점보다는 눈에 띄게 개선된 걸 볼 수 있었습니다.

이후에 포스트에서 easing 관련 애니메이션 관련 그래프들에 대해서 다뤄봐야겠네요.

이 값들에 따라 눈에 띄게 성능 차이가 나는 것을 볼 수 있어서 조금 의아했습니다.

다음은 가장 괜찮았다고 생각되는 값입니다.

  requestAnimationFrame(() => {
    Animated.timing(animationValue, {
      toValue: 0,
      duration: 400,
      easing: Easing.out(Easing.poly(3)),
      useNativeDriver: true,
    })
  });

 

여러 가지를 도전해 보면서

Native 스럽게 만들기 위해서 페이지 애니메이션을 얕보고 도전했다가 큰 코를 다친 거 같습니다 ㅎ...

프로젝트 완성 기한이 얼마 남지 않아서 도전한 게 좀 섣부른 판단이 아니었나 중간에 후회가 되기도 하더라고요 

개인적인 욕심과 프로젝트 기한을 지키는 게 항상 어려운 부분 중에 하나인 거 같습니다.

결과적으로는 시장에 나가서 고객의 피드백을 받는게 우선이니 앞으로는 개인적인 욕심을 더 내려놓는 연습을 해보는 것도 좋을 것 같네요 😃

 

profile

기록의 습관화

@ww8007

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!