React Native Webview에 페이지 전환 트랜지션 적용하기

개요

React NativeWebview 를 사용하여 앱을 개발하고 있다.

웹 뷰를 기반으로 한 하이브리드 앱은 크다면 크고 사소하다면 사소한 문제가 하나 있다. 단순한 웹 뷰만 제공하는 앱이라면 웹 뷰의 페이지 라우팅이 변경될 때 페이지 전환 효과 없이 화면이 깜빡이면서 이동한다는 것이다.

webview-stack-navigation
Fig 1. 일반적인 웹뷰의 페이지 이동

일반적인 React Native 앱으로 개발한다면 React Navigation 을 통해 간단하게 화면 전환 효과를 줄 수 있다. 그러나 웹 뷰 형태에선 페이지 전환할 때 웹에 페이지 전환 애니메이션을 주는 것이 맞는건지, 앱 자체에서 처리를 해야하는지 많은 고민을 해야 했다.

예를 들면, 웹의 라우팅이 변경될 때 앱에서 그 이벤트를 받아 그 라우팅에 해당하는 URL 을 가진 새로운 웹뷰로 이동해야할까? 라던지, 웹의 라우팅이 변경될 때 웹의 전체 페이지에 트랜지션을 넣어야 할 지 등등…

고민 끝에 결정한 방법으로는 React Native Navigation 과 웹에서 라우팅 이벤트 발생 시 postMessage 로 이벤트를 송신하고, 이를 앱에서 WebView 의 onMessage 로 읽어 페이지 전환을 React Native 의 Stack Navigation 형식으로 구현했다.

앱 환경은 React Native + Typescript 이며, 웹 환경은 Next.js + Typescript 이다.

React Native App

현재 개발하는 앱에는 웹 뷰와 React Native 화면 모두 사용한다. 메인 홈 화면, 상품 상세 화면 등은 웹 뷰로 구성되어 있으며 마이 페이지 화면은 React Native 로 구성되어 있다. 이 화면들이 Bottom Tab Navigator 로 구분되어 있다.


import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { NavigationContainer } from '@react-navigation/native';
import {
  createStackNavigator,
  TransitionPresets
} from '@react-navigation/stack';

import WebViewContainer from '@/screen/common/webView/webViewContainer';

const Stack = createStackNavigator();
const Tab = createBottomTabNavigator();

const WebViewTabs = () => {
  return (
    <Stack.Navigator
      initialRouteName='WebViewRoot'
      screenOptions=
    >
      <Stack.Screen
        name='WebViewRoot'
        component={WebViewContainer}
        options=
      />
    </Stack.Navigator>
  );
};

const HomeTabs = () => {
  return (
    <Tab.Navigator
      screenOptions={() => ({
        tabBarIcon: {},
        tabBarStyle: {},
        tabBarActiveTintColor: 'black',
        tabBarInactiveTintColor: 'gray'
      })}
    >
      <Tab.Screen
        name='Home'
        component={WebViewTabs}
        options=
      />
      <Tab.Screen
        name='MyPage'
        component={MyPage}
        options=
      />
    </Tab.Navigator>
  );
};

const Navigation = () => {
  return (
    <NavigationContainer>
      <Stack.Navigator initialRouteName='IntroSplash'>
        <Stack.Group
          screenOptions=
        >
          <Stack.Screen name='IntroSplash' component={Splash} />
          <Stack.Screen name='IntroLogin' component={Login} />
          <Stack.Screen name='HomeTabs' component={HomeTabs} />
        </Stack.Group>
      </Stack.Navigator>
    </NavigationContainer>
  );
};

export default Navigation;

앱의 작동 구조는 다음과 같다.

앱을 오픈하면 스플래쉬 화면이 보이고, 로그인 화면으로 진입한다. 로그인에 성공하면 메인 홈 화면을 띄우게 된다.

기본 홈 화면은 웹 뷰의 홈 화면이며, React Native 로 구성된 마이 페이지로 진입하기 위해 Bottom Tab 이 존재한다.

스플래쉬 - 로그인 - 메인 홈 화면이 Stack Group 으로 묶여있다.

메인 홈 화면이 보여주는 컴포넌트는 HomeTabs 이다. HomeTabs 는 하단 탭을 갖고 있으며 웹 뷰 화면인 홈 화면과 React Native 화면인 마이 페이지로 구분되어 있다.

웹 뷰 화면으로 구성된 홈 화면에 전달한 componentWebViewTabs 이다.

WebViewTabs 에 본격적으로 구현할 웹 뷰 페이지 라우팅 전환 애니메이션을 적용할 것이다. WebViewTabs 은 하나의 Stack Screen 을 갖고 있는 Stack Navigation 이다.

네비게이션 옵션으로는 헤더를 가리기 위한 옵션과 TransitionPresets.SlideFromRightIOS 을 설정했다. 이는 iOS 의 페이지 트랜지션 프리셋을 Android 에도 동일하게 적용하기 위함이다.

Stack ScreencomponentWebViewContainer 를 설정했다. 옵션으로 아래와 같이 transitionSpec 을 설정하면 페이지 전환 애니메이션 속도를 조정할 수 있다.

          transitionSpec: {
            open: {
              animation: 'spring',
              config: {
                stiffness: 2000,
                damping: 1000
              }
            },
            close: {
              animation: 'spring',
              config: {
                stiffness: 1000,
                damping: 500
              }
            }
          }

webViewContainer.tsx

이제 사용자는 로그인을 거치면 가장 먼저 WebViewContainer 화면을 마주하게 된다.

이 컴포넌트에서 웹 뷰의 페이지 라우팅 전환이 발생하면 Stack Navigation 을 통한 페이지 전환이 일어날 수 있도록 onMessage 를 처리해야 한다.

import React from 'react';
import { StackActions } from '@react-navigation/native';
import WebView, { WebViewMessageEvent } from 'react-native-webview';

const WebViewContainer = ({ navigation, route }) => {
  const target = 'http://localhost:3000';
  const url = route.params.url ? target + route.params.url : target;

  const handleMessage = (e: WebViewMessageEvent) => {
    const data = JSON.parse(e.nativeEvent.data);

    switch (data?.type) {
      // 웹뷰의 페이지 라우팅이 변경될 때 작동
      case 'ROUTER_CHANGE':
        const path: string = data.data?.value;
        if (path === 'back') {
          const popAction = StackActions.pop(1);
          navigation.dispatch(popAction);
        } else {
          const pushAction = StackActions.push('WebViewRoot', {
            url: `${path}`,
            isStack: true
          });
          navigation.dispatch(pushAction);
        }
        break;
      case 'DEFAULT':
        break;
    }
  };

  return (
    <WebView
      source=
      onMessage={handleMessage}
      originWhitelist={['*']}
    />
  );
};

export default WebViewContainer;

웹에서 페이지 전환 이벤트가 발생하면 handleMessage 가 작동하며, ROUTER_CHANGE 라는 이벤트 이름으로 보냈기 때문에 이 이벤트에 해당하는 로직을 실행하게 된다.

뒤로 가기 액션인 경우, 현재 최근의 스택을 하나 빼는 것으로 내비게이션 스택이 변경된다. 이외의 경우 내비게이션 푸시 액션으로 페이지 전환을 실행한다.

Web - Next.js

웹에서는 이제 페이지 라우팅 전환이 일어날 때, 전환이 발생한 환경을 파악해야 한다.

일반적인 웹 브라우저라면 기존과 동일하게 라우팅을 수행하면 된다. 그러나 앱 환경일 경우 라우팅 전환이 발생한다는 이벤트를 앱으로 보내야 한다.

WebViewMessage.ts


export const RN_API = {
  ROUTER_CHANGE: 'ROUTER_CHANGE'
};

export const WebViewMessage = async (type: string, data: any) =>
  new Promise((resolve, reject) => {
    if (!window.ReactNativeWebView) {
      return;
    }
    const reqId = Date.now();
    const TIMEOUT = 3000; // 3s
    const timer = setTimeout(() => {
      /** android */
      document.removeEventListener('message', listener);
      /** ios */
      window.removeEventListener('message', listener);

      reject('TIMEOUT');
    }, TIMEOUT);

    const listener = (event: any) => {
      const { data: listenerData, reqId: listenerReqId } = JSON.parse(
        event.data
      );
      if (listenerReqId === reqId) {
        clearTimeout(timer);

        /** android */
        document.removeEventListener('message', listener);
        /** ios */
        window.removeEventListener('message', listener);
        resolve(listenerData);
      }
    };

    window.ReactNativeWebView.postMessage(
      JSON.stringify({
        type,
        data,
        reqId
      })
    );

    /** android */
    document.addEventListener('message', listener);
    /** ios */
    window.addEventListener('message', listener);
  });

하이브리드 앱의 경우 웹 환경에서 앱으로 이벤트를 보내기 위한 postMessage 를 굉장히 자주 사용하기 때문에 이를 따로 만들어 두었다.

WebViewRouter.ts


import { NextRouter } from 'next/router';
import { RN_API, WebViewMessage } from './webViewMessage';

// react native app 환경인지 판단
const IsApp = () => {
  let isApp = false;

  if (typeof window !== 'undefined' && window.ReactNativeWebView) {
    isApp = true;
  }

  return isApp;
};

// 뒤로가기 하는 경우
export const stackRouterBack = (router: NextRouter) => {
  if (IsApp()) {
    WebViewMessage(RN_API.ROUTER_CHANGE, {
      value: 'back'
    });
  } else {
    router.back();
  }
};

// push 하는 경우
export const stackRouterPush = (router: NextRouter, url: string) => {
  if (IsApp()) {
    WebViewMessage(RN_API.ROUTER_CHANGE, {
      value: url
    });
  } else {
    router.push(url).then();
  }
};

재사용을 위해 만든 WebViewMessage 를 활용해 웹에서 페이지 전환이 뒤로 가기인 경우와 푸시인 경우를 분기하여 앱으로 ROUTER_CHANGE 이벤트를 보낸다.

앱이 아니라면 기존 Next.js router 를 통해 그대로 라우팅하게 된다.

이를 이제 웹에서 페이지 전환을 적용하려는 페이지 또는 컴포넌트에 아래와 같이 사용해주면 된다.

import { useRouter } from 'next/router';
import { stackRouterPush, stackRouterBack } from '@/utils/index';

export default function TestPage() {
  const router = useRouter();
  return (
    <>
      <button onClick={() => stackRouterPush(router, `/blahblah`)}>
        PUSH 이동
      </button>
      <button onClick={() => stackRouterBack(router)}>뒤로 가기</button>
    </>
  );
}

모든 과정을 마치면 앱에서 Stack Navigation 이 잘 적용된 것을 확인할 수 있다.

webview-stack-navigation2
Fig 2. 앱에서 웹 뷰의 페이지 전환

References

2022년 11월 29일에 수정됨
YUNSU BAE

YUNSU BAE

주니어 웹 개발자 배윤수 입니다!

예술의 영역을 동경하고 있어요. 🧑‍🎨