Monacaのサンプルアプリである「フォトシェアアプリデザインテンプレート」にバックエンド機能を実装する連載の第3回目。前回のプロフィール編集に続いて、今回は写真アップロード機能を作ります。

その1:認証編
その2:プロフィール編集

ライブラリのインストール

今回はJavaScriptライブラリとして以下を利用します。Monaca IDEのJS/CSSコンポーネントの追加と削除から追加してください。

  • mustache.js(JSテンプレートエンジン)
  • exif-js(EXIF情報を取得するJSライブラリ)
  • timeago.js(経過時間を表示するJSライブラリ)

カメラ画面への遷移を作る

カメラで撮影を行う画面(camera.html)を修正します。写真を選択した時に、その画像のプレビューを表示したいのでプレビュー用のCanvasタグを用意します。

<!-- クラスを追加 -->
<ons-icon class="camera-icon cameraPlaceholder" icon="ion-android-camera"></ons-icon>
<!-- 以下を追加 -->
<canvas id="preview" style="display:none;width:100%;height:100%;"></canvas>

さらに画像データや位置情報を保持するためのタグを用意しておきます。

<div class="camera-button">
  <!-- クラスを追加 -->
  <ons-icon class="camera-icon select-photo" icon="fa-circle-o">
  </ons-icon>
</div>
<input type="file" id="cameraImageFile" />
<input type="hidden" id="location" />
<input type="hidden" id="latitude" />
<input type="hidden" id="longitude" />

このままだと <input type="file"> 標準のファイル選択ボタンが表示されてしまいますので、スタイルシートで消しておきます。

/* ボタンを消す */
#cameraImageFile {
  opacity: 0;
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
}

これでUIは完成です。

カメラ画面のイベントを作る

まずカメラ画面を表示したタイミングで処理を行います。そのため、document の show イベントを設定します。

document.addEventListener('show', function(event) {
  var page = event.target;
  // カメラ画面を表示した際のイベント
  if (page.id == 'camera-page') {
    showCameraPage($(page));
  }
});

ここでは画面の入力要素の初期化と、ファイル選択時のイベントを設定します。

const showCameraPage = (dom) => {
  dom.find('.cameraPlaceholder').show();
  dom.find('#preview').hide();
  dom.find('#latitude').val('');
  dom.find('#longitude').val('');
  dom.find('#location').val('');
  // 写真選択を実行
  dom.find('.select-photo').on('click', (e) => {
    if (ons.platform.isIOS()) {
      $(e.target).click();
    }
    dom.find('#cameraImageFile').click();
  });

  // 写真を選択した際のイベント
  dom.on('change', '#cameraImageFile', (e) => {
    const file = e.target.files[0];
    const fr = new FileReader();
    fr.onload = (e) => {
      const img = new Image();
      img.onload = (e) => {
        loadExif(img)
          .then((exif) => {
            drawImage(img, exif.orientation);
            waitAndUpload();
            return getAddress(exif)
          })
          .then((results) => {
            $('.cameraPlaceholder').hide();
            $('#preview').show();
            $('#latitude').val(results.latitude);
            $('#longitude').val(results.longitude);
            $('#location').val(results.address);
          }, (err) => {
            console.log(err);
          });
      };
      img.src = e.target.result;
    };
    fr.readAsDataURL(file);
  });
}

写真のプレビュー処理について

写真を選択した際には、画像を読み込んでCanvasタグに表示します。その際、写真から位置情報と写真の向きを取得します。iPhoneで撮影した写真をそのまま表示すると上下反転することがありますので、向き情報であるOrientationの取得が必要となります。

const loadExif = (img) => {
  return new Promise((res, rej) => {
    EXIF.getData(img, function() {
      const lat = EXIF.getTag(this, "GPSLatitude");
      const long = EXIF.getTag(this, "GPSLongitude");
      const orientation = EXIF.getTag(this, "Orientation");
      res({
        lat: lat,
        long: long,
        orientation: orientation
      });
    });
  })
}

画像と向き情報を取得したら、Canvasに描画します。向き(Orientation)によってCanvas内の画像を回転させます。

const drawImage = (img, orientation) => {
  const canvas = $("#preview")[0];
  const ctx = canvas.getContext('2d');
  const size = 320;
  const offset = {width: 0, height: 0};
  let rotate = 0;
  let width = height  = size;
  canvas.width = canvas.height = size;
  let originalWidth = img.width;
  let originalHeight = img.height;
  switch (orientation) {
    case 2:
      ctx.translate(width, 0);
      ctx.scale(-1, 1);
      break;
    case 3:
      ctx.translate(width, height);
      ctx.rotate(Math.PI);
      break;
    case 4:
      ctx.translate(0, height);
      ctx.scale(1, -1);
      break;
    case 5:
      ctx.rotate(0.5 * Math.PI);
      ctx.scale(1, -1);
      break;
    case 6:
      ctx.rotate(0.5 * Math.PI);
      ctx.translate(0, -height);
      break;
    case 7:
      ctx.rotate(0.5 * Math.PI);
      ctx.translate(width, -height);
      ctx.scale(-1, 1);
      break;
    case 8:
      ctx.rotate(-0.5 * Math.PI);
      ctx.translate(-width, 0);
      break;
    default:
      break;
  }
  if (originalWidth > originalHeight) {
    // 横長
    width =  320 * originalWidth / originalHeight;
    offset.width = -1 * (width - size) / 2;
  }
  if (originalWidth < originalHeight) {
    // 縦長
    height =  320 * originalHeight / originalWidth;
    offset.height = -1 * (height - size) / 2;
  }
  ctx.drawImage(img, offset.width, offset.height, width, height);
};

さらに位置情報を基に住所を文字列で取得します。これはHeartRails Geo APIを使っています。位置情報から住所情報を返してくれるAPIですが、認証不要で使えるので手軽です。

const getAddress = (exif) => {
  const results = {
    latitude: '',
    longitude: '',
    address: ''
  };
  return new Promise((res, rej) => {
    const lat = exif.lat;
    const long = exif.long;
    if (lat && long) {
    }else{
      return res(results);
    }
    results.latitude = lat[0] + (lat[1]/60) + (lat[2]/(60*60));
    results.longitude = long[0] + (long[1]/60) + (long[2]/(60*60));
    $.ajax({
      url: `https://geoapi.heartrails.com/api/json?method=searchByGeoLocation&y=${results.latitude}&x=${results.longitude}`,
      type: 'GET',
      dataType: 'jsonp'
    })
    .then((response) => {
      const location = response.response.location[0];
      if (location) {
        results.address = `${location.prefecture}${location.city}${location.town}`;
        res(results);
      }
    }, (err) => {
      res(results);
    });
  });
}

写真のアップロード処理

プレビュー表示したら、3秒あけてアップロードして良いか確認します。以下の処理はちょっと長いのですが、写真のアップロード処理を行った後、アップロードした写真のURLとメッセージをデータベースへ保存する処理です。保存処理がうまくいったら、ホーム画面に戻ります。

const waitAndUpload = () => {
  let photoObjectId;
  setTimeout(() => {
    ons.notification.confirm({
      message: 'Do you want to upload?'
    })
    .then((id) => {
      // id == 1 はOKボタンを押した場合です
      if (id != 1) {
        throw 1;
      }
      const promises = [];
      promises.push(ons.notification.prompt({
        message: 'Write your memory!'
      }));
      const file = canvasToBlob();
      const fileName = `${current_user.objectId}-${(new Date()).getTime()}.jpg`;
      promises.push(fileUpload(fileName, file));
      return Promise.all(promises);
    })
    .then((results) => {
      // データベースに写真情報を追加
      const message = results[0];
      const fileUrl = results[1];
      const Photo = ncmb.DataStore('Photo');
      const acl = new ncmb.Acl();
      acl
        .setPublicReadAccess(true)
        .setUserWriteAccess(current_user, true);
      const latitude = $('#latitude').val();
      const longitude = $('#longitude').val();
      const location = $('#location').val();
      const photo = new Photo();
      photo
        .set('user', current_user)
        .set('userObjectId', current_user.objectId)
        .set('fileUrl', fileUrl)
        .set('message', message)
        .set('location', location)
        .set('acl', acl);
      if (latitude != '' && longitude != '') {
        const geoPoint = new ncmb.GeoPoint(Number(latitude), Number(longitude));
        photo.set('geo', geoPoint);
      }
      return photo.save();
    })
    .then((photo) => {
      // Homeに戻る
      $('#tabbar')[0].setActiveTab(0);
      photo.user = ncmb.User.getCurrentUser();
      myPhotos[photo.objectId] = photo;
      photoObjectId = photo.objectId;
      let photo_count = current_user.photo_count || 0;
      photo_count++;
      current_user.set('photo_count', photo_count);
      return current_user.update();
    })
    .catch((err) => {
      console.log(err);
      if (err === 1) {
        return;
      }
      ons.notification.alert(JSON.stringify(err));
    })
  }, 3000);
}

上記の処理の中で呼び出している canvasToBlob は、Canvasタグに入っている写真データを取り出す関数です。Canvasの内容からBlobを生成しています。

const canvasToBlob = () => {
  const type = 'image/jpeg';
  const canvas = $("#preview")[0];
  const dataurl = canvas.toDataURL(type);
  const bin = atob(dataurl.split(',')[1]);
  const buffer = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i += 1) {
    buffer[i] = bin.charCodeAt(i);
  }
  const blob = new Blob([buffer.buffer], {type: type});
  return blob;
}

この機能を有効にするにはニフクラmobile backendのアプリ設定画面で「データ・ファイルストア」の「HTTPSでの取得」を有効に設定する必要があります。

タイムラインを表示する

写真を投稿したら、タイムラインの写真一覧を更新します。まずは初期表示の時点で写真一覧が表示されるようにします。元々の写真一覧画面(home.html)をベースに、mustache.jsのテンプレート形式に変更します。実際の内容はhome.htmlを参照してもらうとして、ここではテンプレート部分を掲載します。

<template id="photo">
  <ons-card id="{{id}}" class="post">
    <ons-list-item class="post_title">
      <div class="left">
        <img class="profile_image list-item__thumbnail" src="{{photo.user.profileImage}}" >
      </div>
      <div class="center">
        <div class="list-item__title"><b>{{photo.user.userName}}</b></div>
        <div class="list-item__subtitle" style="font-size: 12px">{{photo.location}}</div>
      </div>
      <div class="right">
        <ons-button class="corner-button" modifier="quiet">
          <ons-icon icon="ion-ios-more,material:ion-android-more-vertical"></ons-icon>
        </ons-button>
      </div>
    </ons-list-item>

    <div style="text-align: center; position: relative;" ondblclick="like('{{id}}')">
      <ons-icon size="150px" id="post-like-{{id}}" class="post-like" icon="ion-ios-heart"></ons-icon>
      <img class="post-image" width="320" height="320" src="{{photo.fileUrl}}">
    </div>

    <ons-list-item class="post-button-bar" modifier="nodivider">
      <div class="center" style="padding-top: 0px">
        <ons-button class="post-button" modifier="quiet" onclick="like('{{id}}')">
          {{#photo.liked}}
            <ons-icon id="button-post-like-{{id}}" icon="ion-ios-heart" class="ion-ios-heart like"></ons-icon>
          {{/photo.liked}}
          {{^photo.liked}}
            <ons-icon id="button-post-like-{{id}}" icon="ion-ios-heart-outline"></ons-icon>
          {{/photo.liked}}
        </ons-button>
        <ons-button class="post-button" modifier="quiet" onclick="comment('{{id}}')"><ons-icon icon="ion-ios-chatbubble-outline"></ons-icon></ons-button>
        <ons-button class="post-button" modifier="quiet"><ons-icon icon="ion-ios-paperplane-outline"></ons-icon></ons-button>
      </div>
      <div class="right corner-button bookmark">
        <ons-button class="post-button" modifier="quiet"><ons-icon icon="md-bookmark-outline"></ons-icon></ons-button>
      </div>
    </ons-list-item>
    <div class="post-like-info">
      {{{favorite_message}}}
    </div>
    <div class="post-caption"><b>{{photo.user.userName}}</b> {{photo.message}} </div>
    <div class="post-time">{{photo.timeAgo}}</div>

    <div class="post-comments">
      {{{messages}}}
    </div>
  </ons-card>
</template>

変数をプロキシとして作る

写真の一覧を更新する処理にはProxyを利用します。写真を配列で保管している変数をプロキシにして、その変数が更新されたらタイムラインをアップデートする仕組みにします。これは最近のReactやVueなどのVirtualDOMで使われている仕組みと同じです。写真を投稿して myPhotos を更新すると、updateMyPhotos が実行されます。さらに timelinePhotos を更新すると、updateTimeline が実行されてタイムライン表示が更新される仕組みです。

const myPhotos = new Proxy({}, {
  set: (target, key, value) => {
    if (!target[key]) {
      target[key] = value;
      updateMyPhotos($('#grid_view'), target);
    }
    timelinePhotos[key] = value;
  }
});

const timelinePhotos = new Proxy({}, {
  set: (target, key, value) => {
    if (!target[key]) {
      target[key] = value;
      updateTimeline(target);
    }
  }
});

ここまでで、写真の投稿とタイムラインの更新処理が完成しました。写真などのバイナリデータはファイルストアに保存し、そのURLを別途保存しておくとバイナリデータへのアクセス回数も少なく済み、位置情報などのメタ情報をデータベースで管理できるようになります。

ここまでのソースコードはNCMBMania/photoshare at v0.3にアップロードしてあります(前回のプロフィール編集機能も含まれています)。実装時の参考にしてください。