[Expo/ReactNative]WebViewでCanvasを使ってみる

ExpoでCanvasやWebGLを使用するためのパッケージは公式で開発されているみたいなのですが（[Introducing the Canvas 2D Context API for Expo](https://blog.expo.io/introducing-the-canvas-2d-context-api-for-expo-8ba6106ed8e5)）、最新バージョンではなんだかんだでちゃんと使用できなかったので、さくっとWebViewを介してCanvasを使ってみることにしました。

コンポーネント化してみたものがこちら。

```javascript:WebViewCanvas.js
import WebView from 'react-native-webview';
import { View } from 'react-native';
import PropTypes from 'prop-types';
import React from 'react';

export default class WebViewCanvas extends React.Component {
  constructor(props) {
    super(props);
    this.onLoad = this.onLoad.bind(this);
  }

  onLoad() {
    const { onLoad, variables } = this.props;
    Object.keys(variables).forEach((name) => {
      this.webview.injectJavaScript(`var ${name} = ${JSON.stringify(variables[name])};`);
    });
    this.webview.injectJavaScript(`
      var canvas = document.querySelector('canvas');
      canvas.style.width = window.innerWidth + 'px';
      canvas.style.height = window.innerHeight + 'px';
      try {
        (${onLoad.toString()}).apply(null, [canvas, window.ReactNativeWebView.postMessage]);
      } catch (e) {
        window.ReactNativeWebView.postMessage('error: ' + e.message);
      }
    `);
  }

  render() {
    const { width, height } = this.props;
    const html = `
      <html>
        <head></head>
        <body style="margin: 0;">
          <canvas width="${width}" height="${height}" />
        </body>
      </html>
    `;
    return (
      <View style={{ width, height }}>
        <WebView
          style={{ width: '100%', height: '100%' }}
          ref={(ref) => {
            if (ref) {
              this.webview = ref;
            }
          }}
          originWhitelist={["*"]}
          source={{ html }}
          onLoad={this.onLoad}
          onMessage={(event) => {
            console.log(event.nativeEvent.data);
          }}
          onError={(syntheticEvent) => {
            const { nativeEvent } = syntheticEvent;
            console.warn("WebView error: ", nativeEvent);
          }}
        />
      </View>
    );
  }
}

WebViewCanvas.defaultProps = {
  variables: {}
};

WebViewCanvas.propTypes = {
  width: PropTypes.number.isRequired,
  height: PropTypes.number.isRequired,
  onLoad: PropTypes.func.isRequired,
  variables: PropTypes.object
};
```

`react-native-webview`を使用します。linkの必要はありません。
WebView内でのJavaScriptはアプリ側からは文字列として渡す必要があり多少面倒なのですが、意外と簡単に使用できました。
JSはpropsに`injectedJavaScript`を渡すか、直接`injectJavaScript`メソッドを使うかのどちらかの方法があります。
`injectJavaScript`メソッドは`componentDidMount`されていてもWebViewがloadされていないと使用できません（エラーも出ないので嵌りやすいかも）。

```jsx:使用例
const data = [...]; // 外部の変数

...
<WebViewCanvas
  width={200}
  height={200}
  onLoad={(canvas, debug) => {
    const context = canvas.getContext('2d');
    debug('canvas has loaded');
    debug(data);
    ...
  }}
  variables={{ data }}
/>
```

propsのonLoadは最終的に文字列化してWebViewに渡すので、間接的に変数も渡せるようにしてみました。使い勝手悪いですね。。。
Pixiなど、外部ライブラリを読み込むなどする際にはまた工夫が必要だと思います。

これを使って[以前の記事](https://qiita.com/mildsummer/items/20251594c68322ba0fe8)のQRコンポーネントをCanvas化してみると、

```javascript:CanvasQRCode.js
import React from 'react';
import PropTypes from 'prop-types';
import { View } from 'react-native';
import qrcode from 'qrcode-generator';
import times from 'lodash.times';
import WebViewCanvas from './WebViewCanvas';

export default class CanvasQRCode extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = {
      size: null
    };
    this.onLayout = this.onLayout.bind(this);
  }

  componentWillReceiveProps(nextProps, nextContext) {
    if (nextProps.size !== this.props.size) {
      this.setState({
        size: null
      });
    }
  }

  onLayout(e) {
    console.log('layout');
    const { width } = e.nativeEvent.layout;
    if (this.state.size !== width) {
      this.setState({ size: width });
    }
  }

  render() {
    const { data, cellSize, margin, typeNumber, errorCorrectionLevel, color } = this.props;
    const size = this.state.size || this.props.size;
    const QRCode = qrcode(typeNumber, errorCorrectionLevel);
    QRCode.addData(data);
    QRCode.make();
    let calculatedCellSize = cellSize;
    if (typeof calculatedCellSize !== 'number' && typeof size === 'number') {
      calculatedCellSize = typeof margin === 'number' ? Math.round((size - margin * 2) / QRCode.getModuleCount())
        : Math.round(size / (QRCode.getModuleCount() + 8));
    }
    const moduleCount = QRCode.getModuleCount();
    const uri = QRCode.createDataURL(calculatedCellSize, margin);
    const map = times(moduleCount).map((x) => times(moduleCount).map((y) => (QRCode.isDark(x, y))));
    return (
      <View
        key={uri}
        onLayout={this.onLayout}
        style={{
          width: size,
          aspectRatio: 1,
          justifyContent: 'center',
          alignItems: 'center'
        }}
      >
        {calculatedCellSize ? (
          <View
            style={{
              position: 'relative',
              width: calculatedCellSize * moduleCount,
              aspectRatio: 1
            }}
          >
            <WebViewCanvas
              width={calculatedCellSize * moduleCount}
              height={calculatedCellSize * moduleCount}
              onLoad={(canvas) => {
                const context = canvas.getContext('2d');
                context.fillStyle = color;
                map.forEach((row, x) => {
                  row.forEach((isDark, y) => {
                    if (isDark) {
                      context.fillRect(x * calculatedCellSize, y * calculatedCellSize, calculatedCellSize, calculatedCellSize);
                    }
                  });
                });
              }}
              variables={{
                color,
                calculatedCellSize,
                map
              }}
            />
          </View>
        ) : null}
      </View>
    );

  }
}

CanvasQRCode.defaultProps = {
  typeNumber: 0 // 自動
};

CanvasQRCode.propTypes = {
  color: PropTypes.string,
  data: PropTypes.string.isRequired, // データ文字列
  size: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, // 画像の大きさ
  cellSize: PropTypes.number, // セルの大きさ
  margin: PropTypes.number, // 余白の大きさ
  typeNumber: PropTypes.number, // 型番
  errorCorrectionLevel: PropTypes.string.isRequired // 誤り訂正レベル
};
```

```javascript:App.js
import React from 'react';
import { View } from 'react-native';
import QRCode from "./CanvasQRCode";

export default class App extends React.Component {
  render() {
    return (
      <View
        style={{
          flex: 1,
          justifyContent: 'center',
          alignItems: 'center'
        }}
      >
        <QRCode
          data='test qr code'
          size='60%'
          color='lightskyblue'
          errorCorrectionLevel='H'
        />
      </View>
    );
  }
}
```

<img width="386" alt="iPhone_8_—_13_1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/2cdc7f04-2827-2767-b2d2-7aa85e53b593.png">

こんな感じに。
色の変更や飾りつけ（ロゴを載せる等）もこれでできそうですね。