AndroidのCanvasを使ってバーコード読取のファインダーを作り直した話

リサーチ・アンド・イノベーションの高田(tfandkusu)です。こだわりを持ってAndroidアプリ作りができる環境を求めて、今年1月に入社し、CODEAndroidアプリ開発を担当しています。このたびサードパーティーから買っているバーコードデコードライブラリを別のものに差し替えることとなりましたが、そのライブラリには特徴的な仕様があり、それに合わせてバーコードスキャン範囲を示す表示(以下、ファインダー)にも改修を加えることにしました。この記事ではその部分をCanvasを使って再実装したことを紹介します。

バーコード読取画面
バーコード読取画面

ファインダーをよく見る

ファインダーは下の図で示すとおりです。

  • 外側は白い半透明
  • 中心に穴が開いている
  • 4角に不透明の線がある
  • 線には外側だけ陰がある

全体
全体

外側だけに影がある
外側だけに影がある

バーコードデコードライブラリの仕様

バーコードデコードライブラリの仕様ですが、サイズが640x480または480x640の画像のみ対応でした。大きさについては縮小すれば良いですが、縦横比は変えられないのでデコード範囲は縦横比4:3の範囲であると明示する必要があります。よって、カメラプレビューをこのようなルールで切り取ることにしました。

// width、heightはそれぞれカメラプレビューサイズ。
// AndroidのCamera APIの仕様で画面は縦向きでもカメラプレビューは横に倒れている。
val baseHeight = height * (1080 - 96 * 2) / 1080
val baseWidth = baseHeight * 3 / 4
val left = (width - baseWidth) / 2
val top = (height - baseHeight) / 2
val right = (width + baseWidth) / 2
val bottom = (height + baseHeight) / 2

切り取りルール
切り取りルール

カメラプレビューから取り込まれた画像データはleft,top,right,bottomの位置で切り取られ、480x640に縮小します。余談ですが、このあたりの画像処理はOpenCVライブラリで行っています。過去にはqiitaにOpenCVのサイズを減らす記事を投稿したことがあります。

改修前の実装

改修前の実装はこのようなPNGファイルをImageViewで貼り付けていました。

PNGファイル
PNGファイル

今回は画面横幅の (1080 - 96 * 2) / 1080、縦幅はその3/4の領域に穴を開けたいので、機種ごとに画面の縦横比が違うと考えると画像は使えません。

CustomViewを使う

画像が使えなければCustom View Componentsを使いCanvasで描画します。

Viewクラスを継承する

CustomViewはViewクラスを継承して作ります。

class CODEViewfinderView : View {
    /**
     * ソースコードから追加する用コンストラクタ
     */
    constructor(context: Context) : super(context)

    /**
     * XMLから追加する用コンストラクタ
     */
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
}

コンストラクタは4種類ありますが、1引数と2引数のものだけを実装すれば良さそうです。 (参考文献)

Viewの大きさとdpを取得する

Viewの大きさはonSizeChangedメソッドが大きさが変わるたびに呼ばれるので、それをオーバーライドして取得します。CODEのバーコード読取画面では大きさが変わらないので最初の1回だけ実行されます。 dpはresourcesプロパティからdisplayMetricsプロパティをたどって取得できます。

override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) {
    // widthが幅、heightが高さ
    // 1dpのピクセル数
    this.dp = resources.displayMetrics.density
    // 続きます。
}

描画するビットマップを作成する

描画するビットマップを作成します。大きさはViewのサイズと同じです。Canvasクラスのコンストラクタに渡すことで、そのインスタンスから自由自在に図形などを描いて画像を作れます。

private lateinit var bitmap : Bitmap

override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) {
    // 略
    // ビットマップを作成
    this.bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    // Canvasを作成
    val canvas = Canvas(bitmap)
    // 続きます。
}

ビットマップに対して描画する

Porter-Duffモードを使いこなす

ファインダーをビットマップに描画します。まず全体を半透明の白で塗りつぶした後に透明を描画して穴を開けています。ここで重要な行はdrawHoleメソッドの paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC) です。デフォルト設定は PorterDuff.Mode.SRC_OVER になっています。このモードは描画元色のアルファチャンネル(不透明度)を反映した上で描画されます。アルファチャンネル0で黒の矩形を描画しようとしたところ、透明な黒を描画して結果としてなにも描かれません。一方、PorterDuff.Mode.SRC を設定すると、アルファチャンネルを含めてすべての要素を描画元に置き換えます。よって矩形を描いた箇所は穴になります。

ファインダーの穴
ファインダーの穴

/**
 * 描画情報。onDrawメソッドでも使うので、ここで作る
 */
private val paint = Paint()

override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) {
    // 略
    // 全体を半透明の白で塗りつぶす
    drawWhole(canvas)
    // 穴の位置を計算「バーコードデコードライブラリの仕様」節参照
    val baseWidth = width * (1080 - 96 * 2) / 1080
    val baseHeight = baseWidth * 3 / 4
    val left = (width - baseWidth) / 2
    val top = (height - baseHeight) / 2
    val right = (width + baseWidth) / 2
    val bottom = (height + baseHeight) / 2
    // 全体を描画
    drawWhole(canvas)
    // あとでここに増えます。
    // 穴を描画
    drawHole(canvas, left, top, right, bottom)
    // 続きます。
}
/**
 * まず、全体を薄く白く描画
 */
private fun drawWhole(canvas: Canvas) {
    paint.reset()
    paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC)
    paint.color = Color.argb(0xff * 6 / 10, 0xff, 0xff, 0xff)
    paint.maskFilter = null
    // 全体を塗る
    val rect = Rect()
    rect.left = 0
    rect.top = 0
    rect.right = bitmap.width
    rect.bottom = bitmap.height
    canvas.drawRect(rect, paint)
}

/**
 * 穴を開ける
 */
private fun drawHole(canvas: Canvas, left: Int, top: Int, right: Int, bottom: Int) {
    paint.reset()
    // アルファチャンネルをそのまま書く
    paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC)
    paint.color = 0x00000000
    paint.maskFilter = null
    val rect = Rect()
    rect.left = left
    rect.top = top
    rect.right = right
    rect.bottom = bottom
    canvas.drawRect(rect, paint)
}

BlurMaskFilterで陰を描画する

陰がついている4角の線を描画します。

陰がついている4角の線
陰がついている4角の線

まず陰の元となる形状を作成します。

    /**
     * 穴が空いているところの4角に陰を描く
     * @param canvas 描画ハンドル
     * @param left 穴の左
     * @param top 穴の上
     * @param right 穴の右
     * @param bottom 穴の下
     */
    private fun drawEdgeShadows(canvas: Canvas, left: Int, top: Int, right: Int, bottom: Int) {
        // 4角の線の長さ
        val lineLength = (dp * LINE_LENGTH).toInt()
        // 左上の矩形を描画する
        val rect = Rect()
        rect.left = left
        rect.top = top
        rect.right = left + lineLength
        rect.bottom = top + lineLength
        paint.reset()
        paint.color = 0xff000000.toInt()
        canvas.drawRect(rect, paint)  // 後で書き換えます
        //右上、左下、右下も同様に描画する
        rect.left = right - lineLength
        rect.top = top
        rect.right = right
        rect.bottom = top + lineLength
        canvas.drawRect(rect, paint)  // 後で書き換えます
        rect.left = left
        rect.top = bottom - lineLength
        rect.right = left + lineLength
        rect.bottom = bottom
        canvas.drawRect(rect, paint)  // 後で書き換えます
        rect.left = right - lineLength
        rect.top = bottom - lineLength
        rect.right = right
        rect.bottom = bottom
        canvas.drawRect(rect, paint) // 後で書き換えます
    }

陰の元
陰の元

全然違う形ですが大丈夫です。前述の canvas.drawRect(rect, paint) について、描画にBlurMaskFilterを使い外側にぼかせるように書き換えます。

paint.color = 0x44000000
paint.maskFilter = BlurMaskFilter(4 * dp, BlurMaskFilter.Blur.OUTER)
canvas.drawRect(rect, paint)

外側にぼかす
外側にぼかす

これを4角分行った後に、説明が前後していますが、前節のPorter-Duffモードを使った穴の描画を行います。

穴の描画
穴の描画

最後に線を4角の線を合計8本矩形として描画します。

/**
 * 穴が空いているところの4角に線を書く
 * @param canvas 描画先Canvas
 * @param left 穴の左
 * @param top 穴の上
 * @param right 穴の右
 * @param bottom 穴の下
 */
private fun drawEdgeLines(canvas: Canvas, left: Int, top: Int, right: Int, bottom: Int) {
    val lineLength = (dp * LINE_LENGTH).toInt()
    val lineWidth = (dp * LINE_WIDTH).toInt()
    // 左上
    drawEdgeLine(canvas, left, top,
        0, 0, lineLength, lineWidth,
        0, 0, lineWidth, lineLength)
    // 右上
    drawEdgeLine(canvas, right - lineLength, top,
        0, 0, lineLength, lineWidth,
        lineLength - lineWidth, 0, lineLength, lineLength)
    // 左下
    drawEdgeLine(canvas, left, bottom - lineLength,
        0, 0, lineWidth, lineLength,
        0, lineLength - lineWidth, lineLength, lineLength)
    // 右下
    drawEdgeLine(canvas, right - lineLength, bottom - lineLength,
        lineLength - lineWidth, 0, lineLength, lineLength,
        0, lineLength - lineWidth, lineLength, lineLength)
}

/**
 * 穴が空いているところの4角の1角分の線を書く
 * @param canvas 描画先Canvas
 * @param left 角の左上座標
 * @param top 角の上座標
 * @param left1 線1の左
 * @param top1 線1の上
 * @param right1 線1の右
 * @param bottom1 線1の下
 * @param left2 線2の左
 * @param top2 線2の上
 * @param right2 線2の右
 * @param bottom2 線2の下
 */
private fun drawEdgeLine(canvas: Canvas, left: Int, top: Int,
                         left1: Int, top1: Int, right1: Int, bottom1: Int,
                         left2: Int, top2: Int, right2: Int, bottom2: Int) {
    paint.reset()
    paint.color = 0xffffffff.toInt()
    // 線を描画する
    val rect = Rect()
    rect.left = left + left1
    rect.top = top + top1
    rect.right = left + right1
    rect.bottom = top + bottom1
    canvas.drawRect(rect, paint)
    rect.left = left + left2
    rect.top = top + top2
    rect.right = left + right2
    rect.bottom = top + bottom2
    canvas.drawRect(rect, paint)
}

できあがり

最後に作成したBitmapをonDrawメソッドで描画するとできあがりです。

override fun onDraw(canvas: Canvas) {
    paint.reset()
    canvas.drawBitmap(bitmap, 0f, 0f, paint)
}

できあがり
できあがり

求人

リサーチ・アンド・イノベーションでは例えばCanvasを使ってまで細部にこだわりたいAndroidアプリエンジニアを募集しています。

リードAndroidアプリエンジニア(Forkwell)

リードAndroidアプリエンジニア(Paiza)