kintone モバイルで QR バーコードを読み取るカスタマイズしてみた

[kintone Advent Calendar 2019](https://qiita.com/advent-calendar/2019/kintone) 13日目の記事です。

## はじめに
2019年、ついにバーコード決済デビューをしました。IC カード決済と組み合わせて、無敵な気持ちになっています:muscle:

なので、スマートフォン版の kintone で QRコードを読み取るカスタマイズをしてみました！
ポイントは、ネイティブのアプリを作らずに Web ブラウザ上で QR コードを読み取るところです。

このカスタマイズは、**iOS Safari 限定**で動作します。
動作確認は、iPhone XR（iOS 13.2.3）で行いました。

## 完成イメージ

- レコード追加画面上部の[スキャン]ボタンをクリックすると、QRコードを読み取るモーダルが表示されます。
- QRコードを読み取って[OK]ボタンを押すと、ルックアップで商品マスタから商品名を取得します。
- あとは入出庫情報を入力して、レコードを保存します。 

![scan-barcode.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/355972/57ec6930-faf8-afab-4e86-4258de8346a5.gif)

## 必要なもの

- Node.js 10系 環境
- kintone 環境（スタンダードコース）  
  ※ 1年無料で利用できる[開発者ライセンス](https://developer.cybozu.io/hc/ja/articles/200720464)があります。

## kintone アプリの作成
- 入出庫履歴アプリ
  バーコード読み取りカスタマイズを行うアプリです。このアプリに JavaScript カスタマイズをします。

    |フィールド名 |フィールドタイプ |フィールドコード |備考
    |:--|:--|:--|:--
    |商品コード |ルックアップ |itemCode |商品マスタの商品コードに関連付ける
    |商品名 |文字列（1行）  |itemName |商品マスタの商品名からコピー
    |種別 |ラジオボタン |ー |「入庫」「出庫」
    |数量 |数値 |ー |初期値: 0

- 商品マスタ
  入出庫履歴に関連付けるアプリです。こちらはカスタマイズしません。
  あらかじめ、このアプリに商品レコードを追加しておきます。

    |フィールド名 |フィールドタイプ |フィールドコード |備考
    |:--|:--|:--|:--
    |コード |文字列（1行） |itemCode |「値の重複を禁止する」にチェック
    |商品名 |文字列（1行）  |itemName |


## バーコードを読み取るライブラリの入手
2次元バーコードを読み取るライブラリには、[InstaScan](https://github.com/schmich/instascan) を利用します。
ただ、モバイルの Safari では動かないので、[fork した InstaScan](https://github.com/chick-p/instascan)[^1]を用意しました。

[^1]: babelPolyfil の二重追加防止と、iOS Safari 対応をしています。

1. [fork した InstaScan](https://github.com/chick-p/instascan) にアクセスし、リポジトリをダウンロードします。
2. リポジトリのディレクトリ以下で、次のコマンドを実行します。Node.js 10系で実行してください[^2]

    ```bash
    # リポジトリのディレクトリに移動する
    $ cd instascan-master

    # ビルドに必要なツール群をインストールする
    $ npm install

    # ビルド
    $ npx gulp release 
    ```

5. `dist` ディレクトリ以下に、「instascan.min.js」が生成されます。この後の「kintone カスタマイズの適用」で利用します。

[^2]: Node.js v10.16.3（npm 6.9.0）で実行できることを確認しました。


## kintone カスタマイズの適用
「入出庫履歴」アプリに、カスタマイズを適用します。

- スマートフォン用の JavaScript ファイル（次の順で適用）
  1. https://js.cybozu.com/jquery/3.4.1/jquery.min.js
  1. instascan.min.js
  1. customize.js（カスタマイズファイル）

- スマートフォン用の CSS ファイル
  1. customize.css（カスタマイズファイル）

カスタマイズファイル（`customize.js` `customize.css` ）の詳細は次のとおりです。
Safari で動かすので、ES6+ の記法を使っています。

```js:customize.js
(($) => {
  'use strict';
  let camera, result;

  /**
   *  スキャンボタンの HTML を生成する
   * @return {Object} scanButtonHTML
   */
  const getScanBtnHTML = () => {
    return `
      <div class="kpc-header">
        <button type="button" class="kpc-btn js-kcp-button"><i class="fas fa-barcode"></i>&nbsp;&nbsp;スキャン</button>
      </div>
    `;
  };

  /**
   * モーダルの HTML を生成する
   * @return {Object} modalHTML
   */
  const getModalHTML = () => {
    return `
      <div class="kcp-modal">
        <div class="kcp-modal__cointainer js-kcp-modal__cointainer">
          <div class="kpc-modal__header"><i class="fas fa-barcode"></i> QR コードをスキャン</div>
          <div class="kpc-video__container js-kpc-video__container"><video id="video" class="video"></video></div>
        <div class="kcp-modal__result">
          <dl>
            <dt>読み取り結果</dt>
            <dd>
              <input type="text" class="qrcode" name="itemCode" disabled>
            </dd>
          </dl>
        </div>
        <div class="js-kpc-modal__footer kpc-modal__footer">
          <div class="kpc-modal__footer--left"><button name="cancel"> キャンセル</button></div>
          <div class="kpc-modal__footer--right"><button name="ok" disabled> OK</button></div>
        </div>
        </div>
        <div class="kpc-modal__bg js-kpc-modal__bg"></div>
      </div>
    `;
  };

  /**
   * スキャナーオブジェクトを生成する
   * @param {jQuery} $modal
   * @return {Object} Scanner
   */
  const getScanner = ($modal) => {
    const $video = $modal.find('#video');
    const scanner = new Instascan.Scanner({video: $video[0], mirror: false});
    // 読み取れたときのイベントを登録
    scanner.addListener('scan', (_result) => {
      result = _result;
      $('.js-kpc-modal__footer button[name="ok"]').prop('disabled', false);
      $('input[name="itemCode"]').val(result);
    });
    return scanner;
  };

  /**
   * モーダルを開く
   */
  const openModal = () => {
    if ($('.kcp-modal').length > 0) {
      $('.kcp-modal').remove();
    }
    const $modal = $(getModalHTML());
    $('body').append($modal);
    $('input[name="itemCode"]').val('');

    const scanner = getScanner($modal);
    Instascan.Camera.getCameras().then((cameras) => {
      if (cameras.length <= 0) {
        console.error('No cameras found.');
        return;
      }
      camera = cameras[0];
      scanner.start(camera);
    }).catch((err) => {
      console.error(err);
    });

    $('.js-kpc-modal__footer button[name="ok"]').on('click', () => {
      const record = kintone.mobile.app.record.get();
      record.record.itemCode.value = result;
      record.record.itemCode.lookup = true;
      kintone.mobile.app.record.set(record);
      closeModal(scanner);
    });
    $('.js-kpc-modal__footer button[name="cancel"]').on('click', () => {
      closeModal(scanner);
    });
    $('.js-kpc-modal__bg,.js-kcp-modal__cointainer').fadeIn('slow');
  };

  /**
   * モーダルを閉じる
   * @param {Object} scanner スキャナー
   */
  const closeModal = (scanner) => {
    $('.js-kcp-modal__cointainer,.js-kpc-modal__bg').fadeOut('slow', () => {
      $('.js-kpc-modal__bg').remove();
      if (camera) {
        scanner.stop(camera);
      }
    });
  };

  // モバイル版のレコード追加画面を開いたときに実行する
  kintone.events.on('mobile.app.record.create.show', (event) => {
    const $scanBtn = $(getScanBtnHTML());
    $(kintone.mobile.app.getHeaderSpaceElement()).append($scanBtn);
    $scanBtn.find('.js-kcp-button').on('click', () => {
      openModal();
    });
    return event;
  });
})(jQuery);
```

```css:customize.css
.kpc-header {
  padding: 10px;
}

.kpc-btn {
  padding: 8px 12px;
  font-size: 1em;
  font-weight: 700;
  line-height: 1;
  color: #fff;
  background-color: #206694;
  border: solid 2px #206694;
  border-radius: 6px;
}

.kpc-modal__bg {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1;
  display: none;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
}

.kcp-modal__cointainer {
  position: fixed;
  top: 2vh;
  right: 5vh;
  left: 5vh;
  z-index: 2;
  max-height: 95vh;
  background-color: #fff;
  border-radius: 6px 6px 6px 6px;
}

.kpc-modal__header {
  padding: 16px;
  font-size: 1.4rem;
  font-weight: 700;
  line-height: 1.4em;
  border-bottom: 1px solid #d8d8d8;
}

.kpc-video__container {
  position: relative;
  padding: 16px;
  line-height: 1.4em;
  border-bottom: 1px solid #d8d8d8;
}

.video {
  top: 0;
  z-index: 1;
  width: 100%;
}

.kcp-modal__result {
  position: relative;
  -webkit-box-sizing: border-box;
  box-sizing: border-box;
  width: 100%;
  padding: 16px;
}

.kcp-modal__result dl {
  width: 100%;
}

.kcp-modal__result dt {
  margin-bottom: 5px;
  font-weight: 700;
}

.kcp-modal__result dd {
  width: 100%;
  margin-bottom: 5px;
}

.kpc-modal__footer {
  display: flex;
  padding: 10px 16px;
}

.kpc-modal__footer--left {
  display: flex;
  float: left;
  width: 50%;
}

.kpc-modal__footer--right {
  display: flex;
  float: right;
  width: 50%;
  text-align: right;
}

.kpc-modal__footer button {
  box-sizing: border-box;
  display: block;
  min-width: 100px;
  padding: 12px;
  font-weight: 700;
  line-height: 1;
  border: 2px solid #206694;
  border-radius: 6px;
}

.kpc-modal__footer button[name="ok"] {
  margin: 0 0 0 auto;
  color: #fff;
  background-color: #206694;
}

.kpc-modal__footer button[disabled] {
  cursor: default;
  background-color: #a5a5a5;
  border: 2px solid #a5a5a5;
}

.kpc-modal__footer button[name="cancel"] {
  color: #206694;
  background-color: #fff;
}

.kcp-modal__result input {
  -webkit-box-sizing: border-box;
  box-sizing: border-box;
  width: 100%;
  padding: 0.4em;
  border-radius: 0.4em;
  outline: 0;
}

.kcp-modal__result input[disabled] {
  -webkit-box-sizing: border-box;
  box-sizing: border-box;
  color: #999;
  -webkit-text-fill-color: #999;
  background-color: #d5d7d9;
  opacity: 1;
}

.qrcode {
  padding-right: 1.7em;
  margin-right: 0;
  vertical-align: middle;
}
```

## おわりに
ネイティブアプリを作らなくても、少ないコード量の JavaScipt カスタマイズで、QR コードの読み取りができました！