react-native-webview で表示するページを Android の戻るボタンで戻れるようにする…のはわりと簡単だけど「戻れるかどうか」をちゃんと判定するのは結構難しいという話

## 概要

単純に「戻る」だけならシンプルに実装できますが、「これ以上戻れるかどうか」により処理を分けたい場合への対応について、 SPA 対応まで含めて説明している記事がなさそうだったのでまとめました。

「Android の戻るボタン」と書いていますが、 UI で「戻る」ボタンを表示しつつ、それ以上戻れないときは非アクティブにするといった対応にも応用できると思います。

## 1. 単純に戻す

WebView の ref を使って以下のようにすれば戻す操作はできます。

```CustomWebView1.tsx
import {useFocusEffect} from '@react-navigation/native';
import React, {useRef, useCallback} from 'react';
import {BackHandler} from 'react-native';
import WebView from 'react-native-webview';

type Props = {
  uri: string;
};

export const CustomWebView = ({uri}: Props) => {
  const ref = useRef<WebView>(null);

  const onBack = useCallback(() => {
    ref.current?.goBack();

    // true を返すと先に登録されていたイベントハンドラー実行をブロック
    return true;
  }, []);

  useFocusEffect(() => {
    BackHandler.addEventListener('hardwareBackPress', onBack);

    return () => {
      BackHandler.removeEventListener('hardwareBackPress', onBack);
    };
  });

  return <WebView ref={ref} source={{uri}} />;
};

```

こんな感じでしょうか。

## 2. それ以上戻れない場合の処理

しかし、 WebView 内でそれ以上戻れない場合は React Navigation などのルーター側の「戻る」機能を使いたい場合があると思います。
そのような場合は onNavigationStateChange を使って画面遷移が発生するごとに canGoBack を取得しておいて、この値が真のときだけ戻れるようにすると良いかと思います。

```CustomWebView2.tsx
import {useFocusEffect} from '@react-navigation/native';
import React, {useRef, useCallback, useState} from 'react';
import {BackHandler} from 'react-native';
import WebView, {WebViewNavigation} from 'react-native-webview';

type Props = {
  uri: string;
};

export const CustomWebView = ({uri}: Props) => {
  const [canGoBack, setCanGoBack] = useState(false);
  const onNavigationStateChange = useCallback((state: WebViewNavigation) => {
    setCanGoBack(state.canGoBack);
  }, []);

  const ref = useRef<WebView>(null);

  const onBack = useCallback(() => {
    if (canGoBack) {
      ref.current?.goBack();
      return true;
    }

    return false;
  }, [canGoBack]);

  useFocusEffect(() => {
    BackHandler.addEventListener('hardwareBackPress', onBack);

    return () => {
      BackHandler.removeEventListener('hardwareBackPress', onBack);
    };
  });

  return (
    <WebView
      onNavigationStateChange={onNavigationStateChange}
      ref={ref}
      source={{uri}}
    />
  );
};
```

こんな感じでしょうか。

## 3. ページが SPA の場合にも対応する

2 の実装はあくまで Multi Page Application を想定した実装で、 WebView のコンテンツが Single Page Application だった場合、 onNavigationStateChange イベントが発生しないため、うまく動きません。

SPA でウェブで「戻る」操作が実行されたときは popstate イベントが発生するのですが、「戻る」以外の SPA で画面遷移が発生したときにそのイベントを簡単に検知する web 標準仕様は現状ありません（[WHATWGに Issue があります](https://github.com/whatwg/html/issues/5562)）。

いくつか実装パターンがあると思うのですが、ひとつの方法として、 MutationObserver を使って 
 window.location.href の変化を検知し、 postMessage で画面遷移を React Native 側で受け取るようにしてみます。

```CustomWebView3.tsx
import {useFocusEffect} from '@react-navigation/native';
import React, {useRef, useCallback, useState} from 'react';
import {BackHandler} from 'react-native';
import WebView, {
  WebViewMessageEvent,
  WebViewNavigation,
} from 'react-native-webview';

type Props = {
  uri: string;
};

// Android 6 の System WebView のデフォルトバージョンが 44 で
// アロー関数に対応していないので
// Android 6 をサポートする場合、一応、アロー関数の使用は避ける
// const, let を使うには "use strict" も必要
// 実際に System WebView のバージョン更新していないユーザーは稀だと思われるが
// 検証用のシミュレーターで動かない場合があるため
const injectedScript = `
  window.addEventListener('DOMContentLoaded', function () {
    "use strict"
    let oldHref = window.location.href;
    const bodyList = document.querySelector("body")
    const observer = new MutationObserver(function (mutations) {
      mutations.forEach(function (mutation) {
        if (oldHref != window.location.href) {
          oldHref = window.location.href;
          const message = JSON.stringify({
            type: 'pagemove',
            url: window.location.href
          });
        
          window.ReactNativeWebView.postMessage(message);
        }
      });
    });

    const config = {
      childList: true,
      subtree: true
    };

    observer.observe(bodyList, config);
  });
`;

export const CustomWebView = ({uri}: Props) => {
  const [canGoBack, setCanGoBack] = useState(false);

  const onMessage = useCallback(
    (event: WebViewMessageEvent) => {
      const message = event.nativeEvent.data;

      try {
        const data = JSON.parse(message);

        if (data.type === 'pagemove') {
          const {url} = data as {url: string};
          setCanGoBack(url !== uri);
        }
      } catch (e) {
        console.error(e);
      }
    },
    [uri],
  );

  const onNavigationStateChange = useCallback((state: WebViewNavigation) => {
    setCanGoBack(state.canGoBack);
  }, []);

  const ref = useRef<WebView>(null);

  const onBack = useCallback(() => {
    if (canGoBack) {
      ref.current?.goBack();
      return true;
    }

    return false;
  }, [canGoBack]);

  useFocusEffect(() => {
    BackHandler.addEventListener('hardwareBackPress', onBack);

    return () => {
      BackHandler.removeEventListener('hardwareBackPress', onBack);
    };
  });

  return (
    <WebView
      onNavigationStateChange={onNavigationStateChange}
      onMessage={onMessage}
      injectedJavaScriptBeforeContentLoaded={injectedScript}
      javaScriptEnabled
      ref={ref}
      source={{uri}}
    />
  );
};

```

こんな感じでしょうか。

MutationObserver を使った実装の参考先: https://stackoverflow.com/a/46428962