React Native 를 웹뷰 컨테이너로 사용해 웹앱을 구현하고 있다.

웹뷰를 사용한 하이브리드앱 구현 시 주의해야 할 점 중 하나는 안드로이드 기기의 백버튼(뒤로가기 버튼)에 대한 처리다. 사람들은 이전 웹페이지로 돌아가길 기대하며 백버튼을 누르지만, React Native 는 웹뷰 내에서의 이전 페이지로 이동하는 것이 아니라 React Native 상의 이전 화면으로 돌아가기 때문이다.

만약 웹뷰 화면이 해당 앱의 첫 화면이라면, 백버튼을 눌렀을 때 이전 웹페이지로 가는 것이 아니라 앱이 종료되는 것을 경험할 수 있다. 당연하게도 그것은 개발자가 의도한 사항도, 사용자가 의도한 사항도 아니다.

그러면 웹뷰가 있는 화면에서 백버튼을 눌렀을 때 React Native 의 이전 화면이 아닌 웹뷰의 이전 웹페이지가로 가도록 코드를 수정해보자.

1. onNavigationStateChange

React Native WebView 를 사용하고 있다면 onNavigationStateChange 콜백BackHandler API를 사용해 이전 웹페이지로 이동하도록 백버튼의 기능을 조정할 수 있다.

const HomeScreen = (props) => {
  const ref = useRef();
  const [navState, setNavState] = useState();

  useEffect(() => {
    const onPress = () => {
      if (navState.canGoBack) {
        // 뒤로 갈 수 있는 상태라면 이전 웹페이지로 이동한다
        ref.current.goBack();
        // 기본 뒤로가기 동작을 수행하지 않을 거라면 true 를 리턴한다.
        return true;
      } else {
        // 뒤로 갈 수 없는 상태라면
        // 다른 원하는 행동을 하면 된다
        console.log("do something");
        // 기본 뒤로가기 동작을 수행하지 않을 거라면 true 가 아닌 값을 리턴한다.
        return false;
      }
    };

    // 안드로이드 백버튼이 눌렸을 때 이벤트 리스너를 등록한다.
    BackHandler.addEventListener("hardwareBackPress", onPress);
    return () => {
      BackHandler.removeEventListener("hardwareBackPress", onPress);
    };
  }, [navState.canGoBack]);

  return (
    <WebView
      // ...
      ref={ref}
      // 웹뷰의
      onNavigationStateChange={setNavState}
    />
  );
};

onNavigationStateChange 콜백의 첫 번째 인자(navState)는 웹뷰의 현재 상태가 담긴 객체인데, 해당 객체의 canGoBack 값은 현재 웹뷰에서 뒤로가기가 가능한 상태인지를 알려준다 (예를 들어 첫페이지에서는 뒤로 갈 수 없을 것이다. 그런 경우에는 false 값을 가진다).

뒤로 갈 수 있다면 ref.current.goBack()메서드를 사용해 이전 페이지로 돌아가고, 그렇지 않다면 필요한 다른 작업을 하면 되겠다.

2. HTML5 History API 대응

하지만 onNavigationStateChange 콜백에 문제가 있으니, 바로 pushState(), replaceState()HTML5 History API로 추가된 기능으로 페이지 이동을 하면 동작하지 않는다는 것이다. 이는 canGoBack 값이 최신화되지 않는다는 뜻이고, 위에서 작성한 코드가 정상적으로 동작할 수 없다는 뜻이다.

물론 여기서 주저앉을 수는 없다. 이제 우리는 onMessage 콜백webview.injectJavaScript() 메서드를 통해 pushState(), replaceState() 동작 시 웹뷰의 상태를 받아오도록 할 것이다.

2.1. webview.injectJavaScript()

webview.injectJavaScript() 메서드는 웹뷰에 자바스크립트 코드를 삽입할 수 있도록 해주는 메서드이다. 해당 메서드를 통해 pushState()replaceState() 메서드를 래핑해줄 것이다.

const INJECTED_CODE = `
(function() {
  function wrap(fn) {
    return function wrapper() {
      var res = fn.apply(this, arguments);
      window.ReactNativeWebView.postMessage('navigationStateChange');
      return res;
    }
  }

  history.pushState = wrap(history.pushState);
  history.replaceState = wrap(history.replaceState);
  window.addEventListener('popstate', function() {
    window.ReactNativeWebView.postMessage('navigationStateChange');
  });
})();

true;
`;
const HomeScreen = (props) => {
  // ...
  return (
    <WebView
      // ...
      ref={ref}
      onLoadStart={() => ref.current.injectJavaScript(INJECTED_CODE)}
      onNavigationStateChange={setNavState}
    />
  );
};

2.1.1. injectedJavaScript props 는 안 되나?

React Native WebView 에는 webview.injectJavaScript()메서드와 비슷한 기능을 하는 injectedJavaScript prop도 존재한다. 하지만 결정적인 차이점이 존재하는데, webview.injectJavaScript()는 우리가 원하는 때에만 골라서 코드를 삽입할 수 있지만, injectedJavaScriptonLoad 이벤트가 발생할 때마다 코드를 삽입된다는 것이다.

onLoadStart 이벤트는 실제 페이지 이동이 일어날 때만 발생하는데 반해 onLoad 이벤트는 History API 등으로 로딩이 발생할 때도 발생한다. 때문에 같은 코드가 중복적으로 삽입될 수 있고, 우리는 그러한 현상을 피하기 위해 onLoadStartinjectJavaScript() 메서드를 조합해서 코드를 삽입한다.

(단, injectedJavaScriptBeforeContentLoaded prop 을 사용하면 위 코드와 거의 동일한 효과를 낼 수 있다.)

2.1.2. 삽입된 코드는 어떤 코드?

위 코드에는 삽입된 코드가 통문자열로 되어있기 때문에 알아보기 힘들다. 하이라이트된 코드로 다시 살펴보자.

(function () {
  function wrap(fn) {
    return function wrapper() {
      var res = fn.apply(this, arguments);
      window.ReactNativeWebView.postMessage("navigationStateChange");
      return res;
    };
  }

  history.pushState = wrap(history.pushState);
  history.replaceState = wrap(history.replaceState);
  window.addEventListener("popstate", function () {
    window.ReactNativeWebView.postMessage("navigationStateChange");
  });
})();

true;

pushState()replaceState()를 래핑하고 popState 이벤트에 리스너를 붙이는 코드라는 것을 알 수 있다. 모든 코드들은 공통적으로 ReactNativeWebView.postMessage() 메서드를 호출하는 것을 볼 수 있다. 이 메서드가 호출될 때마다 웹뷰는 웹페이지의 상태를 onMessage 콜백으로 받아볼 수 있다.

2.2. onMessage

그럼 onMessage 콜백도 넣어보자.

const HomeScreen = (props) => {
  // ...
  return (
    <WebView
      // ...
      ref={ref}
      onLoadStart={() => ref.current.injectJavaScript(INJECTED_CODE)}
      onNavigationStateChange={setNavState}
      onMessage={({ nativeEvent }) => {
        if (nativeEvent.data === "navigationStateChange") {
          setNavState(nativeEvent);
        }
      }}
    />
  );
};

됐다. 이제 pushState(), replaceState()도 놓치지 않게 되었다.

3. 전체 코드

(아래 코드는 이해를 돕기 위한 의사코드로, 실제로는 동작하지 않을 수도 있다)

const INJECTED_CODE = `
(function() {
  function wrap(fn) {
    return function wrapper() {
      var res = fn.apply(this, arguments);
      window.ReactNativeWebView.postMessage('navigationStateChange');
      return res;
    }
  }

  history.pushState = wrap(history.pushState);
  history.replaceState = wrap(history.replaceState);
  window.addEventListener('popstate', function() {
    window.ReactNativeWebView.postMessage('navigationStateChange');
  });
})();

true;
`;

const HomeScreen = (props) => {
  const ref = useRef();
  const [navState, setNavState] = useState();

  useEffect(() => {
    const onPress = () => {
      if (navState.canGoBack) {
        // 뒤로 갈 수 있는 상태라면 이전 웹페이지로 이동한다
        ref.current.goBack();
      } else {
        // 뒤로 갈 수 없는 상태라면
        // 다른 원하는 행동을 하면 된다
      }
    };

    // 안드로이드 백버튼이 눌렸을 때 이벤트 리스너를 등록한다.
    BackHandler.addEventListener("hardwareBackPress", onPress);
    return () => {
      BackHandler.removeEventListener("hardwareBackPress", onPress);
    };
  }, [navState.canGoBack]);

  return (
    <WebView
      // ...
      ref={ref}
      onLoadStart={() => ref.current.injectJavaScript(INJECTED_CODE)}
      onNavigationStateChange={setNavState}
      onMessage={({ nativeEvent }) => {
        if (nativeEvent.data === "navigationStateChange") {
          setNavState(nativeEvent);
        }
      }}
    />
  );
};

4. 참고