FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

Androidにおける画像のトリミング実装 CropImageView

こんにちわ、加藤です。
最近、GANMA!というアプリにて画像のトリミングを実装する機会があったため、備忘録的に紹介したいと思います。 とはいえ、実に簡易的なものですのでご承知ください。

デモ

CropImageViewを組み込んだ例です。
保存ボタンはCropImageViewとは別に新たに作成しました。

f:id:snuow15:20161101120526p:plain:w300

ちなみにですが、写真は弊社がある住友不動産グランドタワーのエントランスです。
余談ですが、ドラマのロケ地としても結構使用されています。

仕様

  • 任意の画像を正方形に切り抜く
  • 正方形の大きさは固定値(拡大や縮小は不可)
  • 対象画像は固定し、正方形を動かすことで切り抜く範囲を指定する
  • 画面回転は考慮しない(回転をすると正方形は初期位置に戻る)

といった実にシンプルなものです。 使い方はというと、後述する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における画像のトリミングを実装してみました。 要件を一つずつ整理すれば思ったほど複雑ではありませんでした。 もちろん、無料で公開されているライブラリはいくつかありましたが、今回の要件に合わなかったり、正常に動作しなかったりでしたので自作しました。
どなたかの参考になれば幸いです。

求人

www.septeni-original.co.jp