Android CameraX で撮影した画像をピンチ操作で拡大縮小

朝の散歩です。最近は変化がまったくないので柴犬も不思議そうな思い出見ています。
概要
撮った写真の確認して NAS に保存するか決めるようにしたほうがいいので確認画面を作ることにしました。
撮影前の拡大はできるようになりましたので、確認のときも2本指によるピンチ操作による拡大・縮小ができるように考えてみました。
また、2本指による画像の移動ができるようにすることもいっしょに考えてみました。
なんとかできるようになりましたので記録することにします。
3年前の1万円タブレットで動かしてみましたが移動拡大縮小が意外とスムーズで驚きました。
1冊だけでは理解の助けにはならないので買い足しました。
2024年2月26日現在
WEBのみでは断片的で覚えにくいので最初に購入した Kotlin の本です。
Custom View クラスの作成
Custom View クラスの作成を作成します。
ピンチ操作時の動き感知するリスナーの作成をします。これには SimpleOnScaleGestureListener を使います。
2本指スクロールの動き感知するリスナーの作成をします。これには SimpleOnGestureListener を使います。
あと、再描画に必要な関数、描画するビットマップ画像をセットする関数を作成しています。
SimpleOnScaleGestureListener
private ScaleGestureDetector.SimpleOnScaleGestureListener scalegesturelistener = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
// ピンチ操作開始
// タッチした座標を取得
return true;
}
@Override
public boolean onScale(ScaleGestureDetector detector) {
// ピンチ操作中呼ばれる
// 比率を計算
invalidate()
super.onScale(detector);
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
// ピンチ操作終了
super.onScaleEnd(detector);
}
};
SimpleOnGestureListener
private GestureDetector.SimpleOnGestureListener simpleongestureListener = new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onScroll (MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
// スクロールしたとき
invalidate()
super.onScroll(e1, e2, distanceX, distanceY);
return true;
}
@Override
public boolean onDoubleTap (MotionEvent e) {
// ダブルタッチしたとき
invalidate()
super.onDoubleTap(e);
return;
}
};
コンストラクタ
public GestureView (Context context) {
super(context);
this.context = context;
init();
}
public GestureView (Context context, AttributeSet attrs) {
super(context, attrs);
this.context = context;
init();
}
public GestureView (Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.context = context;
init();
}
GestureView.java
Custom View クラスの名前を GestureView とします。クラスの概要は次の通りです。
package org.sibainu.relax.room.scalegesturedetector;
public class GestureView extends View {
private ScaleGestureDetector.SimpleOnScaleGestureListener scalegesturelistener = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override
public boolean onScaleBegin(ScaleGestureDetector detector)
@Override
public void onScaleEnd(ScaleGestureDetector detector)
@Override
public boolean onScale(ScaleGestureDetector detector)
};
private GestureDetector.SimpleOnGestureListener simpleongesturelistener = new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onScroll (MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)
@Override
public boolean onDoubleTap (MotionEvent e)
};
// コンストラクタ
public GestureView (Context context)
public GestureView (Context context, AttributeSet attrs)
public GestureView (Context context, AttributeSet attrs, int defStyleAttr)
// このコールバックが true にならないとリスナーが働きません
@Override
public boolean onTouchEvent (MotionEvent event)
// Canvas クリアーして再描画
@Override
protected void onDraw(Canvas canvas)
// このクラスを実体化した時に表示するビットマップをセットします
public void setImageBitmap(Bitmap bm)
}
次のHPのWEBで説明がありますが、必ず onTouchEvent を呼び出す必要があります。
https://developer.android.com/reference/android/view/ScaleGestureDetector
実際の GestureView.java
package org.sibainu.relax.room.scalegesturedetector;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
public class GestureView extends View {
private Context context;
//表示するビットマップ画像
private Bitmap _bm;
//ピンチ操作開始のX座標
private float _focusx;
//ピンチ操作開始のY座標
private float _focusy;
//画像の縮尺
private float _lastscalefactor = 1.0f;
//画像の描画を設定するオブジェクト
private Matrix drawmatrix = new Matrix();
//Paintオブジェクト
private Paint paint = new Paint();
//ピンチ操作時の動き感知するリスナーの作成します
private ScaleGestureDetector scalegesturedetector;
private ScaleGestureDetector.SimpleOnScaleGestureListener scalegesturelistener =
new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
Log.d("scale","onscalebegin");
//ピンチ操作開始
//開始ジェスチャの焦点の XY 座標を取得します
_focusx = detector.getFocusX();
_focusy = detector.getFocusY();
return super.onScaleBegin(detector);
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
//ピンチ操作終了
super.onScaleEnd(detector);
}
@Override
public boolean onScale(ScaleGestureDetector detector) {
//ピンチ操作中
//縮尺を取得します
_lastscalefactor = detector.getScaleFactor();
//縦横の拡大縮小を設定するマトリックスの作成します
drawmatrix.postScale(_lastscalefactor,
_lastscalefactor,
_focusx,
_focusy);
//これを実行することにより onDraw が発火します
invalidate();
super.onScale(detector);
return true;
}
};
//2本指スクロールの動き感知するリスナーの作成します
private GestureDetector gesturedetector;
private GestureDetector.SimpleOnGestureListener gesturelistener =
new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onScroll (MotionEvent e1,
MotionEvent e2,
float distanceX,
float distanceY) {
Log.d("scroll","onscroll");
// スクロールしたとき処理です
if (e1.getPointerId(0) ==
e2.getPointerId(0)) {
// 画像を移動
drawmatrix.postTranslate(-distanceX, -distanceY);
//これを実行することにより onDraw が発火します
invalidate();
}
return super.onScroll(e1, e2, distanceX, distanceY);
}
// ダブルタップすると初期に戻ります
@Override
public boolean onDoubleTap (MotionEvent e) {
Log.d("doubletap","onDoubleTap");
drawmatrix.reset();
invalidate();
return super.onDoubleTap(e);
}
};
// コンストラクタ
public GestureView (Context context) {
super(context);
this.context = context;
init();
}
public GestureView (Context context, AttributeSet attrs) {
super(context, attrs);
this.context = context;
init();
}
public GestureView (Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.context = context;
init();
}
private void init () {
//リスナーを組み込みます
scalegesturedetector = new ScaleGestureDetector(context, scalegesturelistener);
gesturedetector = new GestureDetector(context, gesturelistener);
}
//必ず呼び出しが必要です
@Override
public boolean onTouchEvent (MotionEvent event) {
super.onTouchEvent(event);
// Gestureの挙動はここを通ります
Log.d("touchevent",String.valueOf(gesturedetector.onTouchEvent(event)));
return scalegesturedetector.onTouchEvent(event) ||
gesturedetector.onTouchEvent(event) ||
super.onTouchEvent(event);
}
//この実行は invalidate() の実行で行われます
@Override
protected void onDraw(Canvas canvas) {
// drawmatrixを適応して移動・拡大縮小します
canvas.save();
canvas.drawBitmap(_bm, drawmatrix, paint);
canvas.restore();
}
public void setImageBitmap(Bitmap bm) {
_bm = bm;
//これを実行することにより onDraw が発火します
invalidate();
}
}
ImageCheckActivity.java
カメラ MainActivity の撮影した画像を確認する Activity です。これは MainActivity から画像の Uri の文字列をパラメータにして遷移して開く Activity です。対になるタブレット画面のレイアウトは activity_check_image.xml です。
copy
package org.sibainu.relax.room.scalegesturedetector;
import androidx.appcompat.app.AppCompatActivity;
import android.content.ContentResolver;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import java.io.InputStream;
public class ImageCheckActivity extends AppCompatActivity {
GestureView gv;
Button bt;
TextView tvinfo2;
TextView tv_info;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_image_check);
// clickリスナークラスをセットします
ImageCheckActivity.clickListener ls = new ImageCheckActivity.clickListener();
bt = findViewById(R.id.bt_end);
bt.setOnClickListener(ls);
tv_info = findViewById(R.id.tv_info);
tv_info.setOnClickListener(ls);
tvinfo2 = findViewById(R.id.tvinfo2);
// 呼び出し元の Activity から Uri を取得します
Intent intent = getIntent();
String uristr = intent.getStringExtra("uri").toString();
Uri uri = Uri.parse(uristr);
// ストリームからビットマップイメージを作成します
ContentResolver resolver = getContentResolver();
try {
// Uri からストリームを取得します
InputStream ips = resolver.openInputStream(uri);
// ビットマップイメージを作成します
final int SCALE = 1;
BitmapFactory.Options imageOptions = new BitmapFactory.Options();
imageOptions.inSampleSize = SCALE;
Bitmap bm = BitmapFactory.decodeStream(ips, null, imageOptions);
// Custom View クラスを実体化します
gv = new GestureView(this);
// 作成したビットマップを表示
gv = findViewById(R.id.cv_gestureview);
gv.setImageBitmap(bm);
} catch (Exception e) {
Log.d("ImageCheckActivity","");
}
}
// clickリスナークラスを作成します
private class clickListener implements View.OnClickListener {
@Override
public void onClick(View view) {
int id = view.getId();
if (id == R.id.bt_end) {
finish();
} else if (id == R.id.tv_info) {
String str = tv_info.getText().toString();
tvinfo2.setText(str);
}
}
}
}
res/layout/activity_image_check.xml
タグの名称に Custom View の名前空間を加えたクラス名を書きます。これだけで Custom View が使えます。
<org.sibainu.relax.room.scalegesturedetector.GestureView
android:id="@+id/cv_gestureview"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/guideline4"
app:layout_constraintHorizontal_bias="0.379"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.2" />
TextView “@+id/tv_info” に onClick アクションができるようにします。次のコードを挿入します。
android:clickable="true"
このようにします。
<TextView
android:id="@+id/tv_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Hello World!"
android:clickable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.641"
app:layout_constraintStart_toStartOf="@+id/guideline4"
app:layout_constraintTop_toBottomOf="@+id/bt_end" />
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ImageCheckActivity">
<Button
android:id="@+id/bt_end"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/bt_end"
app:layout_constraintBottom_toTopOf="@+id/tv_info"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.673"
app:layout_constraintStart_toEndOf="@+id/cv_gestureview"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Hello World!"
android:clickable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.641"
app:layout_constraintStart_toStartOf="@+id/guideline4"
app:layout_constraintTop_toBottomOf="@+id/bt_end" />
<ScrollView
android:id="@+id/scrollView3"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="@+id/guideline4"
app:layout_constraintTop_toTopOf="@+id/guideline5"
app:layout_constraintVertical_bias="1.0">
<TextView
android:id="@+id/tvinfo2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="TextView" />
</ScrollView>
// ここに Custom View の名前空間を加えたクラス名を書きます
<org.sibainu.relax.room.scalegesturedetector.GestureView
android:id="@+id/cv_gestureview"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/guideline4"
app:layout_constraintHorizontal_bias="0.379"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.2" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.85" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="129dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
こんな感じになります。

MainActivity.java
takePhoto() 関数の中にある imagecapture.takePicture の最後に次の3行を追加して画面の遷移を行います。
Intent intent = new Intent(MainActivity.this, ImageCheckActivity.class);
intent.putExtra("uri", uristr);
startActivity(intent);
imagecapture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(this),
new ImageCapture.OnImageSavedCallback() {
@Override
public void onError(ImageCaptureException error) {
Log.e(TAG, "Photo capture failed: " + error.toString(), error);
}
@Override
public void onImageSaved(ImageCapture.OutputFileResults
outputFileResults) {
CharSequence msg = "Photo capture succeeded: " +
outputFileResults.getSavedUri();
Toast.makeText(MainActivity.this,
msg,
Toast.LENGTH_SHORT).show();
Log.d(TAG, msg.toString());
Uri uri = Uri.parse(outputFileResults.getSavedUri().toString());
String uristr = outputFileResults.getSavedUri().toString();
try {
Log.d(TAG, "begin try");
InputStream ips = resolver.openInputStream(uri);
Log.d(TAG, "InputStream");
if (id == R.id.bt_shutter) {
//画像のアップロードの実行
Log.d(TAG,"ftpupload");
uploadNas(FTPUSERNAME,
FTPPASSWORD,
FTPSERVER,
FTPDIRECTORY,
name + ".jpg",
ips);
} else if (id == R.id.bt_post) {
//POSTリクエストの実行(画像の文字エンコード)
Log.d(TAG,"postBody");
Map<String, String> postBody =
requestImageBody(JSONNAME,
JSONID,
JSONACCESS,
JSONKEY,
ips);
Log.d(TAG, "front : " + postBody.get("data"));
uploadJson(JSONSERVER,postBody);
}
ips.close();
} catch (Exception e) {
Log.d(TAG, "画像ファイルエラー");
}
// 追加はこの3行だけです
Intent intent = new Intent(MainActivity.this, ImageCheckActivity.class);
intent.putExtra("uri", uristr);
startActivity(intent);
}
});
manifests
遷移先の ImageCheckActivity の登録を行います。
application 部の最後に次のコードを追加しています。これで android OS に ImageCheckActivity があることを通知します。
<activity
android:name=".ImageCheckActivity"
android:exported="true"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Cameracjavanas_call1"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ImageCheckActivity"
android:exported="true"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
ここまでとします。