はじめに
皆さんこんにちは。React Nativeのカメラ機能を使用するうえで、react-native-cameraというおそらく一番メジャーなカメラ機能のパッケージ(外部モジュール)を使用したのですが、静止画像の大きさを通常の縦長の形ではなくて正方形などの形で保存するということが、iOSでは簡単にできたのですが恐らくパッケージ自体のバグによりAndroidではうまくできませんでした。
最終的に上下の余白をカットして正方形にクロップする処理を行うことで、正方形などの縦長以外の形で画像を保存することができました。(パッケージ自体が修正されるのが一番いいのですが、現時点では私が調べた限りではアプリ側での対応策としては画像をクロップするという方法しかみつかりませんでした・・。)オーバーレイの調整などいろいろと大変だったので最終的にうまく行った方法をサンプルコードとともに紹介します。
この記事でのReact nativeとreact native cameraのバージョンはそれぞれ以下のものを使用しています。
"react-native": "0.57.8"
"react-native-camera": "1.8.0"
Androidで画像が正方形に保存されず、かつ横向きになってしまう問題
React Native Cameraを使用する上で、iOSではプレビュー画面のViewのスタイルを単純に変更して正方形にすることで自動的にプレビュー画面と同じ画面の比率で画像を保存することができました。しかし、Androidでは(バージョンや機種によって多少挙動が異なる部分はあったのですが)プレビュー画面そのものを正方形に表示させることができなかったり、プレビュー画面を正方形に表示させることはできても、保存される画像自体は縦長のままで保存されてしまうという現象に悩まされました。
また、Androidでは画像が保存される際に90度回転された方向で保存されてしまい、保存した画像をサーバー側やアプリ内で表示させる際に保存された通りの90度回転された横向きの状態で表示されてしまうという問題も発生しましたので、こちらの対策として画像を回転させて保存するという処理も併せて行う必要がありました。
(ちなみに本記事とは関係はないのですが、iOSでは正しい方向で画像は保存されるのですが、画像に付与されるexif情報では「-90度に回転」という情報がiOSのみ含まれていました。アプリ内では問題なかったのですがサーバーで表示させる際に90度回転した状態で表示されてしまい、React Nativeでexif情報を削除する方法がわからなかったのでこちらに関しては画像サーバーにアップロードする段階で画像の方向を修正するという処理を行いました・・。)
オーバーレイでプレビュー画面を正方形に表示
iOSでは必要がないのですが、Androidではreact-native-cameraモジュールのプレビュー画面の大きさを調整できなかったので、オーバーレイを表示させることでプレビュー画面の上下部分を隠すことでプレビュー画面を正方形のように表示させました。(react-native-cameraのバージョンは1.8.0を使用していますが、バージョンによってはこの処理は必要なくプレビュー画面をViewのスタイルを変更することで大きさが変えられることもあるようですが、その場合でも保存される画像自体は縦向きになっていて、加工が必要なようです。)
ここで正方形に加工する画像と、プレビュー画像を厳密に合わせるために以下のような形で上下に表示させるオーバーレイの高さを計算しました。注意点は、iOSとは異なり、Androidでは上部に表示させるステータスバーの高さの分を計算に入れる必要があるという点です。iOSではプレビュー画面のスタイルを変えることができるのでオーバーレイ自体を表示させる必要がそもそもないのですが、ここではオーバーレイの高さをiOS/Androidで調整することで正方形に加工する画像と、プレビュー画像を合わせるようにしています。
ここでの計算でステータスバーの高さを取得するために、以下のようにStatusBarモジュールをインポートする必要があります。
import { StatusBar } from 'react-native';
表示するオーバーレイの高さoverlayHeightは正方形の場合は以下のようにして計算しています。
const { width: windowWidth, height: windowHeight } = Dimensions.get('window');
const navHeight = Platform.OS === 'android' ? StatusBar.currentHeight : 0;
const overlayHeight = (windowHeight - navHeight - windowWidth) / 2;
画像を90度回転させる
正方形の画像の上下の余白をカットする前に、横向きで保存されてしまっている画像を90度回転させる必要があったのですが、これを実現するためにreact-native-image-rotateというパッケージを使用しました。 https://github.com/dgladkov/react-native-image-rotate
パッケージのインストール
$ npm install react-native-image-rotate
ネイティブのライブラリとリンクさせる
$ react-native link react-native-image-rotate
下に上げたコードでは、右方向に90度画像を回転させて、rotatedUriというURIに画像が回転済みの保存されます。cache直下にReactNative_rotated_image_1090087420378345175.jpgのような形のファイル名で保存されることになります。
const imagePath = '/image_path.jpg'; // 元の画像のパス
ImageRotate.rotateImage(
imagePath,
90,
(rotatedUri) => {
// 回転が成功したときの処理。
// rotatedUriが回転された画像のURIでcache直下に
// ReactNative_rotated_image_1090087420378345175.jpgのような形で回転済みの画像が保存される。
},
(error) => {
// エラーのときの処理
},
);
サンプルコードでは、rotateImage()というメソッドで画像の回転を行っていますが、回転→クロップというような順番で非同期で処理を行う必要があるので、Promiseを使っています。また、回転の方向は常に同じ(常に-90度回転された状態で保存)で、かつ機種によっては正しい方向で保存される場合もあったので、「画像の横の長さ>画像の縦の長さ」の場合のみ回転の処理を行っています。
画像の上下の余白を切り取る
次に回転の処理を行ったあとに縦長で保存された画像の上下の余白を切り取る(クロップする)処理を行います。
こちらは外部のパッケージなどを利用する必要はなく、ImageEditorというReact Native自体に含まれるモジュールを使用して該当の処理を行って正方形、または横向きなどの形に画像を加工することができます。
import { ImageEditor } from 'react-native';
im画像の上下の余白を切り取るメソッド ここでは画像にクロップ処理を行うことで1:1の比率の正方形に加工していますが、クロップする画像の高さとオフセットを調整することで任意の比率の画像に加工することができます。
例えば下のコードでは、クロップを開始するオフセットの位置( x: 0, y: 100 )から幅500, 高さ300の大きさで画像を切り出すことができます。
const imagePath = '/image_path.jpg'; // 元の画像のパス
const cropData = {
offset: { x: 0, y: 100 },
size: { width: 500, height: 300 },
};
ImageEditor.cropImage(
imagePath,
cropData,
(croppedUri) => {
// クロップが成功したときの処理。
// croppedUriがクロップされた画像のURIでcache直下に
// ReactNative_cropped_image_89995556232220742.jpgのような形でクロップ済みの画像が保存される。
},
(error) => {
// エラーケース
},
);
サンプルコードではcropImage()というメソッドで上下の余白を切り取る処理を行っています。サンプルでは画像にクロップ処理を行うことで1:1の比率の正方形に加工していますが、クロップする画像の高さとオフセットを調整することで任意の比率の画像に加工することができます。
まとめ
react-native-cameraを使う場合にiOSでは簡単にできる正方形での画像保存のために、Androidではこのようにいろいろと面倒な処理をする必要がありました。いずれはパッケージ自体が修正されるとは思いますが、それまでの間で正方形などの形で保存する場合がある時は、是非参考にしてみてください。(もしもっといい方法があるよー、という場合はコメント欄等で教えていただけると幸いです!)
サンプルコード
import React, { Component } from 'react';
import {
Platform,
StyleSheet,
View,
TouchableOpacity,
Dimensions,
StatusBar,
ImageEditor,
} from 'react-native';
import { RNCamera } from 'react-native-camera';
import ImageRotate from 'react-native-image-rotate';
export default class CameraTest extends Component {
constructor(props) {
super(props);
// 本サンプルでは簡略化のために画像に関するこれらの変数を処理のたびに上書きしています。
this.imagePath = '';
this.imageWidth = 0;
this.imageHeight = 0;
}
async takePicture() {
if (this.camera) {
const { uri, width, height } = await this.camera.takePictureAsync();
[this.imagePath, this.imageWidth, this.imageHeight] = [uri, width, height];
if (Platform.OS === 'android') {
try {
// 非同期処理で回転・クロップと順番に処理を行う
await this.rotateImage();
await this.cropImage();
// 成功ケース
console.log('成功', this.imagePath);
} catch (error) {
// エラーケース
console.log('エラー', error);
}
}
}
}
// 横向きに保存されてしまっている場合のみ、画像を90度回転させて縦長に保存する
rotateImage() {
return new Promise((resolve, reject) => {
if (this.imageWidth > this.imageHeight) {
ImageRotate.rotateImage(
this.imagePath,
90,
(rotatedUri) => {
this.imagePath = rotatedUri;
[this.imageWidth, this.imageHeight] = [this.imageHeight, this.imageWidth];
// cache直下にReactNative_rotated_image_1090087420378345175.jpgのような形で回転済みの画像が保存される。
console.log('回転完了', this.imagePath);
resolve();
},
(error) => {
reject(error);
},
);
} else {
resolve();
}
});
}
// 縦長に保存された画像の上下の余分な部分を切り取って正方形に加工する
cropImage() {
return new Promise((resolve, reject) => {
if (this.imageWidth < this.imageHeight) {
const croppedHeight = this.imageWidth; // ここでクロップする高さを指定。1:1に切り出したいので高さが幅になるように指定
const offsetY = (this.imageHeight - croppedHeight) / 2;
const cropData = {
offset: { x: 0, y: offsetY },
size: { width: this.imageWidth, height: croppedHeight },
};
ImageEditor.cropImage(
this.imagePath,
cropData,
(croppedUri) => {
this.imagePath = croppedUri;
this.imageHeight = croppedHeight;
// cache直下にReactNative_cropped_image_89995556232220742.jpgのような形でクロップ済みの画像が保存される。
console.log('クロップ完了', this.imagePath);
resolve();
},
(error) => {
reject(error);
},
);
} else {
resolve();
}
});
}
render() {
// Androidの場合のみ、ナビゲーションバーの高さを計算に入れる必要がある
const { width: windowWidth, height: windowHeight } = Dimensions.get('window');
const navHeight = Platform.OS === 'android' ? StatusBar.currentHeight : 0;
const overlayHeight = (windowHeight - navHeight - windowWidth) / 2;
return (
<View style={styles.container}>
<View style={[styles.overlayTop, { height: overlayHeight }]} />
<RNCamera
ref={(ref) => {
this.camera = ref;
}}
style={styles.preview}
/>
<View style={[styles.overlayBottom, { height: overlayHeight }]}>
<TouchableOpacity
onPress={this.takePicture.bind(this)}
style={styles.captureButton}
/>
</View>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
zIndex: 0,
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
overlayTop: {
zIndex: 1,
position: 'absolute',
top: 0,
width: '100%',
backgroundColor: '#dbdbdb',
},
overlayBottom: {
zIndex: 1,
position: 'absolute',
bottom: 0,
width: '100%',
backgroundColor: '#dbdbdb',
justifyContent: 'center',
alignItems: 'center',
},
preview: {
zIndex: 0,
width: '100%',
aspectRatio: 1, // Androidではここでのプレビュー画面の比率の指定が効かない...
},
captureButton: {
height: 50,
width: 50,
borderWidth: 3,
borderRadius: 25,
borderColor: 'white',
},
});