AndroidのCanvasを使ってバーコード読取のファインダーを作り直した話
リサーチ・アンド・イノベーションの高田(tfandkusu)です。こだわりを持ってAndroidアプリ作りができる環境を求めて、今年1月に入社し、CODEのAndroidアプリ開発を担当しています。このたびサードパーティーから買っているバーコードデコードライブラリを別のものに差し替えることとなりましたが、そのライブラリには特徴的な仕様があり、それに合わせてバーコードスキャン範囲を示す表示(以下、ファインダー)にも改修を加えることにしました。この記事ではその部分を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で貼り付けていました。
今回は画面横幅の (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角に陰を描く * @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アプリエンジニアを募集しています。