Androidでドット絵エディタを作りたいと思ったことはありませんか?
私は「Dottie」というドット絵アプリを開発・公開しています。この記事では、その開発で得た知見をもとに、Androidでドット絵エディタを実装する方法を解説します。
完成イメージ
この記事で作るドット絵エディタの機能:
- グリッド表示されたキャンバス
- タップでピクセルを描画
- ピンチ操作でズーム・パン
- カラーピッカーで色選択
- PNG形式で保存
1. 基本設計
データ構造
ドット絵は2次元配列で表現します。
class PixelCanvas(
val width: Int, // キャンバス幅(ピクセル数)
val height: Int // キャンバス高さ(ピクセル数)
) {
// 各ピクセルの色を保持(ARGB形式)
private val pixels = Array(height) { IntArray(width) { Color.TRANSPARENT } }
fun setPixel(x: Int, y: Int, color: Int) {
if (x in 0 until width && y in 0 until height) {
pixels[y][x] = color
}
}
fun getPixel(x: Int, y: Int): Int {
return if (x in 0 until width && y in 0 until height) {
pixels[y][x]
} else {
Color.TRANSPARENT
}
}
fun clear() {
pixels.forEach { row -> row.fill(Color.TRANSPARENT) }
}
}
なぜBitmapではなく配列?
Bitmapを直接編集する方法もありますが、以下の理由で配列を使います:
- アンドゥ・リドゥが実装しやすい(状態のコピーが軽量)
- レイヤー機能の拡張が容易
- 描画と表示を分離できる
2. キャンバスViewの実装
カスタムViewを作成し、ドット絵を描画します。
class PixelEditorView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
// キャンバスデータ
var pixelCanvas = PixelCanvas(32, 32)
set(value) {
field = value
invalidate()
}
// 描画用Paint
private val pixelPaint = Paint().apply {
style = Paint.Style.FILL
}
private val gridPaint = Paint().apply {
style = Paint.Style.STROKE
color = Color.LTGRAY
strokeWidth = 1f
}
// 表示用の変換行列
private val transformMatrix = Matrix()
private var scaleFactor = 1f
private var translateX = 0f
private var translateY = 0f
// 現在の描画色
var currentColor = Color.BLACK
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.save()
canvas.concat(transformMatrix)
val pixelSize = calculatePixelSize()
// ピクセルを描画
for (y in 0 until pixelCanvas.height) {
for (x in 0 until pixelCanvas.width) {
val color = pixelCanvas.getPixel(x, y)
if (color != Color.TRANSPARENT) {
pixelPaint.color = color
canvas.drawRect(
x * pixelSize,
y * pixelSize,
(x + 1) * pixelSize,
(y + 1) * pixelSize,
pixelPaint
)
}
}
}
// グリッドを描画
drawGrid(canvas, pixelSize)
canvas.restore()
}
private fun calculatePixelSize(): Float {
val viewSize = minOf(width, height).toFloat()
val canvasSize = maxOf(pixelCanvas.width, pixelCanvas.height)
return viewSize / canvasSize
}
private fun drawGrid(canvas: Canvas, pixelSize: Float) {
// 縦線
for (x in 0..pixelCanvas.width) {
canvas.drawLine(
x * pixelSize, 0f,
x * pixelSize, pixelCanvas.height * pixelSize,
gridPaint
)
}
// 横線
for (y in 0..pixelCanvas.height) {
canvas.drawLine(
0f, y * pixelSize,
pixelCanvas.width * pixelSize, y * pixelSize,
gridPaint
)
}
}
}
3. タッチ操作でピクセルを描画
タップした位置のピクセルに色を塗る処理を実装します。
class PixelEditorView : View {
// ... 前述のコード ...
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_MOVE -> {
drawPixelAt(event.x, event.y)
return true
}
}
return super.onTouchEvent(event)
}
private fun drawPixelAt(touchX: Float, touchY: Float) {
// タッチ座標をピクセル座標に変換
val pixelSize = calculatePixelSize()
// 変換行列の逆行列を使って、実際のキャンバス座標を取得
val inverse = Matrix()
transformMatrix.invert(inverse)
val points = floatArrayOf(touchX, touchY)
inverse.mapPoints(points)
val pixelX = (points[0] / pixelSize).toInt()
val pixelY = (points[1] / pixelSize).toInt()
// ピクセルに色を設定
pixelCanvas.setPixel(pixelX, pixelY, currentColor)
invalidate()
}
}
4. ピンチズーム・パンの実装
ドット絵エディタでは、細かい部分を編集するためにズーム機能が必須です。
class PixelEditorView : View {
// ジェスチャー検出器
private val scaleDetector = ScaleGestureDetector(
context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
scaleFactor *= detector.scaleFactor
scaleFactor = scaleFactor.coerceIn(0.5f, 10f)
updateTransformMatrix()
return true
}
}
)
private val gestureDetector = GestureDetector(
context,
object : GestureDetector.SimpleOnGestureListener() {
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
// 2本指でのスクロール(パン)
if (e2.pointerCount == 2) {
translateX -= distanceX
translateY -= distanceY
updateTransformMatrix()
return true
}
return false
}
}
)
private fun updateTransformMatrix() {
transformMatrix.reset()
transformMatrix.postTranslate(translateX, translateY)
transformMatrix.postScale(scaleFactor, scaleFactor, width / 2f, height / 2f)
invalidate()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
gestureDetector.onTouchEvent(event)
// 1本指のタッチは描画
if (event.pointerCount == 1 && !scaleDetector.isInProgress) {
when (event.action) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_MOVE -> {
drawPixelAt(event.x, event.y)
}
}
}
return true
}
}
ポイント:描画とジェスチャーの分離
1本指は描画、2本指はズーム・パンと分けることで、直感的な操作を実現しています。
5. 描画ツールの実装
ペンだけでなく、消しゴムや塗りつぶしも実装してみましょう。
enum class DrawTool {
PEN,
ERASER,
FILL
}
class PixelEditorView : View {
var currentTool = DrawTool.PEN
private fun drawPixelAt(touchX: Float, touchY: Float) {
val (pixelX, pixelY) = touchToPixelCoord(touchX, touchY)
when (currentTool) {
DrawTool.PEN -> {
pixelCanvas.setPixel(pixelX, pixelY, currentColor)
}
DrawTool.ERASER -> {
pixelCanvas.setPixel(pixelX, pixelY, Color.TRANSPARENT)
}
DrawTool.FILL -> {
floodFill(pixelX, pixelY, currentColor)
}
}
invalidate()
}
// 塗りつぶし(Flood Fill)アルゴリズム
private fun floodFill(startX: Int, startY: Int, newColor: Int) {
val targetColor = pixelCanvas.getPixel(startX, startY)
if (targetColor == newColor) return
val queue = ArrayDeque<Pair<Int, Int>>()
queue.add(Pair(startX, startY))
while (queue.isNotEmpty()) {
val (x, y) = queue.removeFirst()
if (x !in 0 until pixelCanvas.width ||
y !in 0 until pixelCanvas.height) continue
if (pixelCanvas.getPixel(x, y) != targetColor) continue
pixelCanvas.setPixel(x, y, newColor)
queue.add(Pair(x + 1, y))
queue.add(Pair(x - 1, y))
queue.add(Pair(x, y + 1))
queue.add(Pair(x, y - 1))
}
}
}
塗りつぶしの注意点
再帰で実装するとスタックオーバーフローになりやすいので、キューを使った幅優先探索で実装しています。
6. PNG形式で保存
作成したドット絵を画像ファイルとして保存します。
fun PixelCanvas.toBitmap(scale: Int = 1): Bitmap {
val bitmap = Bitmap.createBitmap(
width * scale,
height * scale,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
val paint = Paint()
for (y in 0 until height) {
for (x in 0 until width) {
val color = getPixel(x, y)
if (color != Color.TRANSPARENT) {
paint.color = color
canvas.drawRect(
(x * scale).toFloat(),
(y * scale).toFloat(),
((x + 1) * scale).toFloat(),
((y + 1) * scale).toFloat(),
paint
)
}
}
}
return bitmap
}
// 保存処理
fun saveBitmap(context: Context, bitmap: Bitmap, filename: String) {
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "$filename.png")
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
}
val uri = context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
)
uri?.let {
context.contentResolver.openOutputStream(it)?.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
}
}
scaleパラメータについて
32×32ピクセルのドット絵をそのまま保存すると小さすぎます。scaleパラメータで拡大保存できるようにしています。例えばscale=10なら320×320ピクセルの画像になります。
7. さらに発展させるには
基本的なドット絵エディタができたら、以下の機能を追加するとより実用的になります:
| 機能 | 難易度 | ポイント |
|---|---|---|
| アンドゥ・リドゥ | ★★☆ | 操作履歴をスタックで管理 |
| レイヤー機能 | ★★★ | 複数のPixelCanvasを重ねて描画 |
| アニメーション | ★★★ | フレームごとにキャンバスを切り替え |
| カラーパレット | ★☆☆ | 色の配列をRecyclerViewで表示 |
| グリッドの色変更 | ★☆☆ | ユーザー設定で切り替え |
まとめ
この記事では、Androidでドット絵エディタを実装する基本的な方法を解説しました。
ポイントをまとめると:
- ピクセルデータは2次元配列で管理 → 拡張性が高い
- Matrixで座標変換 → ズーム・パンに対応
- 1本指と2本指でジェスチャーを分離 → 直感的なUX
- 塗りつぶしは幅優先探索で → スタックオーバーフロー回避
実際に私が開発した「Dottie」では、これらの基本機能に加えて、レイヤー、アニメーション、スプライトシート出力などの機能を実装しています。
ドット絵制作に興味がある方は、ぜひ試してみてください。
質問やフィードバックがあれば、お気軽にどうぞ!

