はじめに
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チャンクの構造
項目 | サイズ | 値 |
---|---|---|
wasDeserialized | 1 | 1(true) |
xDivsSize | 1 | xDivの数 |
yDivsSize | 1 | yDivの数 |
colorFieldSize | 1 | divの数 |
skip | 8 | 0 |
paddingLeft | 4 | paddingLeftの値 |
paddingRight | 4 | paddingRightの値 |
paddingTop | 4 | paddingTopの値 |
paddingBottom | 4 | paddingBottomの値 |
skip | 4 | 0 |
xDivs | 4 * xDivs.size | xDivの位置 |
yDivs | 4 * yDivs.size | yDivの位置 |
colorField | 4 * xDivs.size * yDivs.size | 1(NO_COLOR) |
9-patchのDiv
拡大部分の境界のことです。
拡大部分は複数指定することができます。
注意するべきなのは9-patchのindexについてです。
9-patchは四隅の情報がいらないため、一辺については両端を切り落として考えます。
つまり9-patchのindexはBitmapの1から始まります。
bitmap-indexes: 0, 1, 2, 3, 49patch-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 = trueimageView.background = ninePatchDrawableninePatchDrawable.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 | |
} | |
} |
左の画像を右のように引き延ばせます。