Bitmapから9-patchに変換する

2022-02-26
AndroidKotlin
    はじめに
    ローカルの9-patch
    未コンパイル9-patchの変換
    9-patchチャンクの構造
    9-patchのDiv
    Padding
    Converter

はじめに

9-patchは画像の外周1pxに黒色のドットを置くことで拡大個所を指定する画像形式です。
Androidで主に使用されています。

ローカルの9-patch

drawableディレクトリにある「9.png」のファイルは普通のDrawableと同じように使用すれば機能します。
drawableに保存されている9-patchの画像は、apkの作成時にコンパイルされます。
そのため、assetsディレクトリやリモートにある「9.png」はそのままではうまくいかないようです。

未コンパイル9-patchの変換

未コンパイルの9-patchは外周にドットのあるただの画像なので9-patchに変換する必要があります。
9-patch用のクラス「NinePatch」を生成するには、
外周1pxを取り除いた元画像と、
元画像の外周1pxをもとにPNGチャンクを作成する必要があります。

9-patchチャンクは解説コメントがAndroidのコードに載っています。

9-patchチャンクの構造

項目サイズ
wasDeserialized11(true)
xDivsSize1xDivの数
yDivsSize1yDivの数
colorFieldSize1divの数
skip80
paddingLeft4paddingLeftの値
paddingRight4paddingRightの値
paddingTop4paddingTopの値
paddingBottom4paddingBottomの値
skip40
xDivs4 * xDivs.sizexDivの位置
yDivs4 * yDivs.sizeyDivの位置
colorField4 * xDivs.size * yDivs.size1(NO_COLOR)

9-patchのDiv

拡大部分の境界のことです。
拡大部分は複数指定することができます。

注意するべきなのは9-patchのindexについてです。
9-patchは四隅の情報がいらないため、一辺については両端を切り落として考えます。
つまり9-patchのindexはBitmapの1から始まります。

bitmap-indexes: 0, 1, 2, 3, 4
9patch-indexes: 0, 1, 2

Padding

9-patchにはPaddingが設定でき、
NinePatchDrawableからはPaddingが取得できます。

しかし、このPaddingは伸縮しないため、
伸縮させたい場合は以下のようにPaddingを設定するコードを描く必要があります。

val bitmap = resources.assets.open("nine_patch.png").let(BitmapFactory::decodeStream)
val ninePatchDrawable = BitmapToNinePatchConverter(resources, bitmap)
val scaleX = (imageView.width.toFloat() / ninePatchDrawable.intrinsicWidth)
val scaleY = (imageView.height.toFloat() / ninePatchDrawable.intrinsicHeight)
val padding = Rect()
imageView.cropToPadding = true
imageView.background = ninePatchDrawable
ninePatchDrawable.getPadding(padding)
imageView.setPadding(
(padding.left * scaleX).toInt(),
(padding.top * scaleY).toInt(),
(padding.right * scaleX).toInt(),
(padding.bottom * scaleY).toInt()
)

Converter

BitmapからNinePatchDrawableにするコードを書きました。
これを利用すればローカルだけでなくリモートの9-patchも利用できると思います。

object BitmapToNinePatchConverter {
operator fun invoke(resources: Resources, bitmap: Bitmap): NinePatchDrawable {
val trimmedBitmap = trimBitmap(bitmap)
val info = NinePatchBorderReader(bitmap).getPatchInfo()
return NinePatchDrawable(
resources,
trimmedBitmap,
info.ninePatchChunk,
info.padding,
""
)
}
private fun trimBitmap(bitmap: Bitmap) = bitmap.let {
Bitmap.createBitmap(bitmap, 1, 1, it.width - 2, it.height - 2)
}
}
private class NinePatchBorderReader(private val bitmap: Bitmap) {
private val imageWidth = bitmap.width - 2
private val imageHeight = bitmap.height - 2
fun getPatchInfo(): NinePatchInfo {
val xDividers = getOneSideDividers(imageWidth,::horizontalPatchChecker)
val yDividers = getOneSideDividers(imageHeight, ::verticalPatchChecker)
return NinePatchInfo(xDividers, yDividers, getPadding())
}
// 9-patch border starts at 1 index of bitmap
// bitmap-indexes: 0, 1, 2, 3, 4
// 9patch-indexes: 0, 1, 2
private fun horizontalPatchChecker(x: Int, y:Int = 0) = bitmap.isPatchPosition(x + 1, y)
private fun verticalPatchChecker(y: Int, x:Int = 0) = bitmap.isPatchPosition(x, y + 1)
private tailrec fun getOneSideDividers(
imageSideSize: Int,
patchChecker: (Int) -> Boolean,
beforeIsPatched: Boolean = false,
currentBorderIndex: Int = 0,
dividers: List<Int> = emptyList()
): List<Int> {
if(currentBorderIndex > imageSideSize) return dividers
val currentPositionIsPatched = patchChecker(currentBorderIndex)
val isDivider = beforeIsPatched != currentPositionIsPatched
val nextDividers = when {
isDivider -> dividers + currentBorderIndex
else -> dividers
}
return getOneSideDividers(
imageSideSize,
patchChecker,
currentPositionIsPatched,
currentBorderIndex + 1,
nextDividers
)
}
private fun getPadding(): Rect {
val (left, right) = getOneSidePaddings(imageWidth) {
horizontalPatchChecker(it, imageHeight + 1)
}
val (top, bottom) = getOneSidePaddings(imageHeight) {
verticalPatchChecker(it, imageWidth + 1)
}
return Rect(left, top, right, bottom)
}
private fun getOneSidePaddings(size: Int, checkPatch: (Int) -> Boolean) =
(0..size).let { it.first(checkPatch) to size - it.last(checkPatch) - 1 }
private fun Bitmap.isPatchPosition(x: Int, y: Int) = this.getPixel(x, y) == Color.BLACK
}
private class NinePatchInfo(
private val xDividers: List<Int>,
private val yDividers: List<Int>,
val padding: Rect
) {
val ninePatchChunk: ByteArray
get() {
val buffer = ByteBuffer.allocate(bufferCapacity).order(ByteOrder.nativeOrder())
buffer.put(WAS_SERIALISED)
.put((xDividers.size).toByte())
.put((yDividers.size).toByte())
.put(colorSize.toByte())
.position(buffer.position() + BEFORE_PADDING_SKIP_BYTE_SIZE)
buffer.putInt(padding.left)
buffer.putInt(padding.right)
buffer.putInt(padding.top)
buffer.putInt(padding.bottom)
buffer.position(buffer.position() + AFTER_PADDING_SKIP_BYTE_SIZE)
xDividers.forEach(buffer::putInt)
yDividers.forEach(buffer::putInt)
repeat(colorSize) { buffer.putInt(NO_COLOR) }
return buffer.array()
}
private val colorSize = xDividers.size * yDividers.size
private val bufferCapacity: Int
get() {
val xDividersByteSize = ONE_DIVISION_SIZE * xDividers.size
val yDividersByteSize = ONE_DIVISION_SIZE * yDividers.size
val colorByteSize = colorSize * ONE_DIVISION_SIZE
return (
HEAD_BYTE_SIZE +
BEFORE_PADDING_SKIP_BYTE_SIZE +
PADDINGS_BYTE_SIZE +
AFTER_PADDING_SKIP_BYTE_SIZE +
xDividersByteSize +
yDividersByteSize +
colorByteSize
)
}
companion object {
private const val NO_COLOR= 1
private const val WAS_SERIALISED: Byte = 1
private const val HEAD_BYTE_SIZE = 4
private const val BEFORE_PADDING_SKIP_BYTE_SIZE = 8
private const val PADDINGS_BYTE_SIZE = 16
private const val AFTER_PADDING_SKIP_BYTE_SIZE = 4
private const val ONE_DIVISION_SIZE = 4
}
}

左の画像を右のように引き延ばせます。

9-patch画像エミュレーター
© 2022 シロボタン - Context Composition