こんにちわ、加藤です。
最近、GANMA!というアプリにて画像のトリミングを実装する機会があったため、備忘録的に紹介したいと思います。
とはいえ、実に簡易的なものですのでご承知ください。
デモ
CropImageViewを組み込んだ例です。
保存ボタンはCropImageViewとは別に新たに作成しました。
ちなみにですが、写真は弊社がある住友不動産グランドタワーのエントランスです。
余談ですが、ドラマのロケ地としても結構使用されています。
仕様
- 任意の画像を正方形に切り抜く
- 正方形の大きさは固定値(拡大や縮小は不可)
- 対象画像は固定し、正方形を動かすことで切り抜く範囲を指定する
- 画面回転は考慮しない(回転をすると正方形は初期位置に戻る)
といった実にシンプルなものです。 使い方はというと、後述するCropImageViewのsetBitmapにて対象画像をセットします。 次に切り抜く範囲を指定して、getCroppedBitmapにて対象の正方形画像を受け取るという流れになります。
実装
まずは手っ取り早くCropImageViewの全貌を掲載します。
言語はscalaですので、javaで使用する場合は読みかえていただければと思います。
解説は後ほど。
package com.COMICSMART.GANMA.view.common import android.content.Context import android.graphics.Paint.Style import android.graphics._ import android.util.AttributeSet import android.view.{MotionEvent, View} import com.COMICSMART.GANMA.R class Coordinates(var x: Float, var y: Float) class Size(var w: Int, var h: Int) class CropImageView(context: Context, attrs: AttributeSet) extends View(context, attrs) { private val viewSize: Size = new Size(0, 0) private val cropSize: Size = new Size(0, 0) private val center: Coordinates = new Coordinates(0f, 0f) private val prevCenter: Coordinates = new Coordinates(0f, 0f) private val bmpPaint = new Paint() private val cropPaint = new Paint() private val framePaint = new Paint() private val overlayPaint = new Paint() private var guideCircle: Boolean = false private var frameWeight: Float = 0f private var imageBmp: Bitmap = null private var overlayBmp: Bitmap = null private var overlayCanvas: Canvas = null setAttributes() override def onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int): Unit = { super.onSizeChanged(w, h, oldw, oldh) init() } override def onDraw(canvas: Canvas): Unit = { super.onDraw(canvas) overlayCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.SRC_OUT) overlayCanvas.drawRect(0, 0, viewSize.w, viewSize.h, overlayPaint) if (guideCircle) { overlayCanvas.drawCircle(center.x, center.y, cropSize.h / 2, cropPaint) } else { drawSquareToCenter(cropPaint) } drawSquareToCenter(framePaint) canvas.drawBitmap(imageBmp, viewSize.w / 2 - cropBmpW / 2, viewSize.h / 2 - cropBmpH / 2, bmpPaint) canvas.drawBitmap(overlayBmp, 0, 0, bmpPaint) } override def onTouchEvent(e: MotionEvent): Boolean = { e.getAction match { case MotionEvent.ACTION_MOVE => moveCenter(e.getX() - prevCenter.x, e.getY() - prevCenter.y) invalidate() case _ => } prevCenter.x = e.getX() prevCenter.y = e.getY() true } override def onDetachedFromWindow(): Unit = { super.onDetachedFromWindow() imageBmp = null } def setBitmap(_bitmap: Bitmap): Unit = { imageBmp = _bitmap } def getCroppedBitmap: Bitmap = { val point = normalizeBmpPoint Bitmap.createBitmap(imageBmp, point.x, point.y, cropSize.w, cropSize.h) } private def cropBmpW = imageBmp.getWidth private def cropBmpH = imageBmp.getHeight private def difW: Float = viewSize.w - cropBmpW private def difH: Float = viewSize.h - cropBmpH private def init(): Unit = { viewSize.w = getMeasuredWidth viewSize.h = getMeasuredHeight center.x = viewSize.w / 2 center.y = viewSize.h / 2 overlayBmp = Bitmap.createBitmap(viewSize.w, viewSize.h, Bitmap.Config.ARGB_8888) overlayCanvas = new Canvas(overlayBmp) resizeBmp() setPaint() setCropSize() } private def resizeBmp(): Unit = { if (cropBmpW > cropBmpH) { if (viewSize.h > viewSize.w * cropBmpH / cropBmpW) { imageBmp = Bitmap.createScaledBitmap(imageBmp, viewSize.w, viewSize.w * cropBmpH / cropBmpW, false) } else { imageBmp = Bitmap.createScaledBitmap(imageBmp, viewSize.h * cropBmpW / cropBmpH, viewSize.h, false) } } else { if (viewSize.w > viewSize.h * cropBmpW / cropBmpH) { imageBmp = Bitmap.createScaledBitmap(imageBmp, viewSize.h * cropBmpW / cropBmpH, viewSize.h, false) } else { imageBmp = Bitmap.createScaledBitmap(imageBmp, viewSize.w, viewSize.w * cropBmpH / cropBmpW, false) } } } private def setPaint(): Unit = { cropPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)) framePaint.setStyle(Style.STROKE) } private def setAttributes(): Unit = { val attributes = context.obtainStyledAttributes(attrs, R.styleable.CropImageView) framePaint.setColor(attributes.getColor(R.styleable.CropImageView_frame_color, 0)) overlayPaint.setColor(attributes.getColor(R.styleable.CropImageView_overlay_color, 0)) guideCircle = attributes.getBoolean(R.styleable.CropImageView_guide_circle, false) frameWeight = attributes.getDimension(R.styleable.CropImageView_frame_stroke_weight, 0) attributes.recycle() } private def setCropSize(): Unit = { // 正方形の前提で計算している if (cropBmpW > cropBmpH) { cropSize.w = cropBmpH * 2 / 3 cropSize.h = cropBmpH * 2 / 3 } else { cropSize.w = cropBmpW * 2 / 3 cropSize.h = cropBmpW * 2 / 3 } } /** * クロップ領域もしくはフレームを描画 */ def drawSquareToCenter(paint: Paint): Unit = { overlayCanvas.drawRect( center.x - cropSize.w / 2, center.y - cropSize.h / 2, center.x + cropSize.w / 2, center.y + cropSize.h / 2, paint ) } /** * 移動前と移動後の差分から次の中心座標を決定する */ private def moveCenter(difX: Float, difY: Float): Unit = { val nextCenter = new Coordinates(center.x + difX, center.y + difY) // クロップ領域の横幅が画像の内側に収まらない場合の調整 if (nextCenter.x - cropSize.w / 2 < difW / 2) { center.x = difW / 2 + cropSize.w / 2 } else if (nextCenter.x + cropSize.w / 2 > viewSize.w / 2 + cropBmpW / 2 - frameWeight) { center.x = viewSize.w / 2 + cropBmpW / 2 - frameWeight - cropSize.w / 2 } else { center.x = nextCenter.x } // クロップ領域の縦幅が画像の内側に収まらない場合の調整 if (nextCenter.y - cropSize.h / 2 < difH / 2) { center.y = difH / 2 + cropSize.h / 2 } else if (nextCenter.y + cropSize.h / 2 > viewSize.h / 2 + cropBmpH / 2 - frameWeight) { center.y = viewSize.h / 2 + cropBmpH / 2 - frameWeight - cropSize.h / 2 } else { center.y = nextCenter.y } } /** * viewと画像の各幅の差分を隠蔽した(画像基準)座標を返す */ private def normalizeBmpPoint: Point = { var x = center.x - cropSize.w / 2 - difW / 2 var y = center.y - cropSize.h / 2 - difH / 2 if (x < 0) x = 0 if (y < 0) y = 0 if (x + cropSize.w > cropBmpW) cropSize.w = (cropBmpW - x).toInt if (y + cropSize.h > cropBmpH) cropSize.h = (cropBmpH - y).toInt new Point(x.toInt, y.toInt) } }
解説
まず、Viewを継承したCropImageViewを宣言して各変数の定義およびsetAttributesを行っています。
次に、onSizedChangeにて初期化処理を行っています。
// 重要な2行 overlayBmp = Bitmap.createBitmap(viewSize.w, viewSize.h, Bitmap.Config.ARGB_8888) overlayCanvas = new Canvas(overlayBmp)
- resizeBitmapにて対象画像を画面の大きさに合わせてリサイズしています。 こちらの処理を怠ると、大きなBitmapを与えた場合に動作がカクカクする端末があるので要注意です。
- setPaintではPaintの初期化を、setCropSizeでは正方形の大きさ(ここでは画像の2/3とする)を初期化しています。
onDrawでは実際の描画を行っています。
- 図形を描画しているだけといえど以外と複雑です。特に、引数のcanvasではなくoverlayCanvasに描画している点が最大の肝です。 先ほど挙げた重要な2行とも密接に関わります。
- guideCircleは正方形に内接する円で、丸く切り抜く際に使用するためのものです。
onTouchEventではタッチイベントを受け取り、正方形の中心座標(重心)を決定しています。
- タッチイベントの制御はこの実装の要とも言える部分です。
ほとんどが座標に関する処理なので。 とはいえ、正方形が画面からはみ出さないように制御しているだけですけど。
最後に、切り抜く際にnormalizeBmpPointを行っています。
- 正方形の座標はあくまでもviewを基準にした座標なので、それを画像基準の座標に変換しています。
- ただし、当然画像は縦横共にviewよりも小さいですから、座標がviewに収まるように細工を施しています。
以上、簡単にですが解説を行いました。
まとめ
簡易ではありますが、Androidにおける画像のトリミングを実装してみました。
要件を一つずつ整理すれば思ったほど複雑ではありませんでした。
もちろん、無料で公開されているライブラリはいくつかありましたが、今回の要件に合わなかったり、正常に動作しなかったりでしたので自作しました。
どなたかの参考になれば幸いです。