はじめに
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 | |
| } | |
| } |
左の画像を右のように引き延ばせます。
![]() | ![]() |


