Jetpack Compose Canvas Freehand Drawing

Touch and Draw and Save to Gallery: Implementing Freehand Drawing in Jetpack Compose Canvas


Touch and Draw and Save to Gallery: Implementing Freehand Drawing in Jetpack Compose Canvas

Jetpack Compose opens up exciting possibilities for creative and interactive Android applications. One powerful use case is a free-hand drawing app—and in this tutorial, we will build a full-featured drawing app from scratch using Jetpack Compose and Kotlin.

This drawing app supports:
  • Finger-based freehand drawing
  • Color selection
  • Brush thickness adjustment
  • Undo last stroke
  • Save the drawing as a PNG image in the device’s gallery

know more about👉👉 Canvas Graphics

🧠 Learning Points

Through this project, you've learned:
  1. Jetpack Compose UI building
  2. Gesture detection with detectDragGestures
  3. State management with remember and mutableStateListOf
  4. Canvas drawing using Compose and traditional android.graphics.Canvas
  5. Saving images to the gallery using MediaStore
Let's walk through the code and structure in detail. 👇

📦 Package and Imports

The app starts with importing essential Android and Compose libraries. We use:
  • android.graphics.Canvas and Path to draw on a bitmap
  • MediaStore and ContentValues to save images
  • Jetpack Compose UI components like Canvas, Slider, Button, Column, and more
  • Kotlin mutableStateListOf and remember for state management
These are critical for handling the drawing input, managing paths, and saving output.

Let’s dive in step-by-step!

📁 Step 1: Project Setup

Create a new Android Studio project with Jetpack Compose support.

✅ Minimum Requirements:
  • minSdkVersion 24 or above
  • targetSdkVersion 33+
  • Jetpack Compose Material 3 or BOM
Add permissions in AndroidManifest.xml (for Android 9 and below):
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
For Android 10+, storage permission is not required for MediaStore, but we’ll get there.

🧱 Step 2: Create a Data Class to Store Path Points

Every time the user touches the screen, we’ll save the coordinates, color, and stroke width as a PathPoint.
// Data class for each point in the path
data class PathPoint(
val x: Float,
val y: Float,
val isNewLine: Boolean = false,
val color: Color = Color.Black,
val strokeWidth: Float = 6f
)
This allows us to draw smooth lines and separate strokes.

Each touch interaction is stored as a PathPoint. This allows us to reconstruct the entire drawing history. We also store color and stroke width per point, enabling multi-color and multi-size strokes.
  • x and y – Coordinates of the touch point
  • isNewLine – Flags when a new stroke begins
  • color – Stroke color at that point
  • strokeWidth – Brush thickness

🎨 Step 3: Drawing Canvas Composable

Now let’s build a custom composable that uses Jetpack Compose’s Canvas and pointerInput to track touch movement.

@Composable
fun DrawingCanvas(
modifier: Modifier = Modifier,
pathPoints: SnapshotStateList<PathPoint>,
selectedColor: Color,
strokeWidth: Float
) {
val pointerModifier = Modifier.pointerInput(selectedColor, strokeWidth) {
detectDragGestures(
onDragStart = { offset ->
// Start a new path line
pathPoints.add(
PathPoint(
x = offset.x,
y = offset.y,
isNewLine = true,
color = selectedColor,
strokeWidth = strokeWidth
)
)
},
onDrag = { _, dragAmount ->
// Add points continuously while dragging
val last = pathPoints.last()
pathPoints.add(
PathPoint(
x = last.x + dragAmount.x,
y = last.y + dragAmount.y,
color = selectedColor,
strokeWidth = strokeWidth
)
)
}
)
}
androidx.compose.foundation.Canvas(modifier = modifier.then(pointerModifier)) {
var currentPath = ComposePath()
for (i in pathPoints.indices) {
val point = pathPoints[i]
if (point.isNewLine || i == 0) {
if (i != 0) {
val prev = pathPoints[i - 1]
drawPath(
path = currentPath,
color = prev.color,
style = Stroke(width = prev.strokeWidth)
)
currentPath = ComposePath()
}
currentPath.moveTo(point.x, point.y)
} else {
currentPath.lineTo(point.x, point.y)
}
}

if (pathPoints.isNotEmpty()) {
val last = pathPoints.last()
drawPath(
path = currentPath,
color = last.color,
style = Stroke(width = last.strokeWidth)
)
}
}
}

🧱 Step 4: Function - Convert Drawing to Bitmap

We use Android’s native canvas for final rendering before saving.

fun createBitmapFromPath(pathPoints: List<PathPoint>, width: Int, height: Int): Bitmap {
val bitmap = createBitmap(width, height)
val canvas = AndroidCanvas(bitmap)
val paint = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
}

var path = AndroidPath()
for (i in pathPoints.indices) {
val point = pathPoints[i]
paint.color = point.color.toArgb()
paint.strokeWidth = point.strokeWidth

if (point.isNewLine || i == 0) {
if (i != 0) {
canvas.drawPath(path, paint)
path = AndroidPath()
}
path.moveTo(point.x, point.y)
} else {
path.lineTo(point.x, point.y)
}
}

if (pathPoints.isNotEmpty()) {
canvas.drawPath(path, paint)
}

return bitmap
}

Step 5: Drawing UI – DrawingScreen()

This is the main UI screen. It includes:

🖌️ 1. Drawing Canvas

The actual area to draw on, connected to user gestures.

🎨 2. Color Picker

A row of clickable colored circles lets users select the brush color. The selected color is visually highlighted.

🧵 3. Stroke Width Slider

A Slider lets users choose brush thickness from 2 to 30 pixels.

🧹 4. Action Buttons

Buttons allow the user to:
  • Clear all drawings
  • Undo the last stroke
  • Save the current drawing to the gallery
The Undo logic smartly removes points until it finds the start of the previous stroke 

(isNewLine = true).

@Composable
fun DrawingScreen() {
val pathPoints = remember { mutableStateListOf<PathPoint>() }
var selectedColor by remember { mutableStateOf(Color.Black) }
var strokeWidth by remember { mutableFloatStateOf(6f) }
val context = LocalContext.current

Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.systemBarsPadding()
) {
DrawingCanvas(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
pathPoints = pathPoints,
selectedColor = selectedColor,
strokeWidth = strokeWidth
)

Spacer(modifier = Modifier.height(12.dp))

// Color picker row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
listOf(Color.Black, Color.Red, Color.Green, Color.Blue, Color.Magenta).forEach { color ->
Box(
modifier = Modifier
.size(40.dp)
.background(color, CircleShape)
.clickable { selectedColor = color }
.border(
width = if (selectedColor == color) 3.dp else 1.dp,
color = Color.Gray,
shape = CircleShape
)
)
}
}

Spacer(modifier = Modifier.height(12.dp))

// Stroke width slider
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text("Brush: ${strokeWidth.toInt()} ")
Slider(
value = strokeWidth,
onValueChange = { strokeWidth = it },
valueRange = 2f..30f,
modifier = Modifier.width(200.dp)
)
}

Spacer(modifier = Modifier.height(12.dp))

// Action buttons: Clear, Undo, Save
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth()
) {
Button(onClick = { pathPoints.clear() }) {
Text("Clear")
}
Button(onClick = {
// Undo last stroke (remove points until previous isNewLine)
for (i in pathPoints.size - 1 downTo 0) {
val point = pathPoints.removeAt(i)
if (point.isNewLine) break
}
}) {
Text("Undo")
}
Button(onClick = {
val width = 1080
val height = 1920
val bitmap = createBitmapFromPath(pathPoints, width, height)
saveBitmapToPictures(context, bitmap, "drawing_${System.currentTimeMillis()}.png")
Toast.makeText(context, "Saved Drawing!", Toast.LENGTH_SHORT).show()
}) {
Text("Save to PNG")
}
}
}
}

💾 Step 6: Function – saveBitmapToPictures()

This function uses MediaStore to store the drawn bitmap into the Pictures/MyDrawings/ folder.
All drawings are saved in Pictures/MyDrawings/ with filenames like:
drawing_1717288924573.png
Open your Gallery or File Manager and navigate to that folder to find your art!

fun saveBitmapToPictures(context: Context, bitmap: Bitmap, fileName: String = "my_drawing.png") {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/MyDrawings")
put(MediaStore.Images.Media.IS_PENDING, 1)
}

val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)

if (imageUri != null) {
resolver.openOutputStream(imageUri).use { out ->
out?.let { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
}

contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(imageUri, contentValues, null, null)

Toast.makeText(context, "Saved to Pictures/MyDrawings", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Failed to save image", Toast.LENGTH_SHORT).show()
}
}

Key points:

  • We use IS_PENDING to ensure the file is saved properly before it's made visible
  • Compress to PNG with 100% quality
  • Displays success/failure using Toast

Final Cd:

package com.codingbihar.basicofjetpackcomposein30days

import android.content.ContentValues
import android.graphics.Canvas as AndroidCanvas
import android.graphics.Path as AndroidPath
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Paint
import android.provider.MediaStore
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.compose.ui.graphics.Path as ComposePath
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.Color



// Data class for each point in the path
data class PathPoint(
val x: Float,
val y: Float,
val isNewLine: Boolean = false,
val color: Color = Color.Black,
val strokeWidth: Float = 6f
)

@Composable
fun DrawingCanvas(
modifier: Modifier = Modifier,
pathPoints: SnapshotStateList<PathPoint>,
selectedColor: Color,
strokeWidth: Float
) {
val pointerModifier = Modifier.pointerInput(selectedColor, strokeWidth) {
detectDragGestures(
onDragStart = { offset ->
// Start a new path line
pathPoints.add(
PathPoint(
x = offset.x,
y = offset.y,
isNewLine = true,
color = selectedColor,
strokeWidth = strokeWidth
)
)
},
onDrag = { _, dragAmount ->
// Add points continuously while dragging
val last = pathPoints.last()
pathPoints.add(
PathPoint(
x = last.x + dragAmount.x,
y = last.y + dragAmount.y,
color = selectedColor,
strokeWidth = strokeWidth
)
)
}
)
}
androidx.compose.foundation.Canvas(modifier = modifier.then(pointerModifier)) {
var currentPath = ComposePath()
for (i in pathPoints.indices) {
val point = pathPoints[i]
if (point.isNewLine || i == 0) {
if (i != 0) {
val prev = pathPoints[i - 1]
drawPath(
path = currentPath,
color = prev.color,
style = Stroke(width = prev.strokeWidth)
)
currentPath = ComposePath()
}
currentPath.moveTo(point.x, point.y)
} else {
currentPath.lineTo(point.x, point.y)
}
}

if (pathPoints.isNotEmpty()) {
val last = pathPoints.last()
drawPath(
path = currentPath,
color = last.color,
style = Stroke(width = last.strokeWidth)
)
}
}
}

fun createBitmapFromPath(pathPoints: List<PathPoint>, width: Int, height: Int): Bitmap {
val bitmap = createBitmap(width, height)
val canvas = AndroidCanvas(bitmap)
val paint = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
}

var path = AndroidPath()
for (i in pathPoints.indices) {
val point = pathPoints[i]
paint.color = point.color.toArgb()
paint.strokeWidth = point.strokeWidth

if (point.isNewLine || i == 0) {
if (i != 0) {
canvas.drawPath(path, paint)
path = AndroidPath()
}
path.moveTo(point.x, point.y)
} else {
path.lineTo(point.x, point.y)
}
}

if (pathPoints.isNotEmpty()) {
canvas.drawPath(path, paint)
}

return bitmap
}

fun saveBitmapToPictures(context: Context, bitmap: Bitmap, fileName: String = "my_drawing.png") {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/MyDrawings")
put(MediaStore.Images.Media.IS_PENDING, 1)
}

val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)

if (imageUri != null) {
resolver.openOutputStream(imageUri).use { out ->
out?.let { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
}

contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(imageUri, contentValues, null, null)

Toast.makeText(context, "Saved to Pictures/MyDrawings", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Failed to save image", Toast.LENGTH_SHORT).show()
}
}

@Composable
fun DrawingScreen() {
val pathPoints = remember { mutableStateListOf<PathPoint>() }
var selectedColor by remember { mutableStateOf(Color.Black) }
var strokeWidth by remember { mutableFloatStateOf(6f) }
val context = LocalContext.current

Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.systemBarsPadding()
) {
DrawingCanvas(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
pathPoints = pathPoints,
selectedColor = selectedColor,
strokeWidth = strokeWidth
)

Spacer(modifier = Modifier.height(12.dp))

// Color picker row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
listOf(Color.Black, Color.Red, Color.Green, Color.Blue, Color.Magenta).forEach { color ->
Box(
modifier = Modifier
.size(40.dp)
.background(color, CircleShape)
.clickable { selectedColor = color }
.border(
width = if (selectedColor == color) 3.dp else 1.dp,
color = Color.Gray,
shape = CircleShape
)
)
}
}

Spacer(modifier = Modifier.height(12.dp))

// Stroke width slider
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text("Brush: ${strokeWidth.toInt()} ")
Slider(
value = strokeWidth,
onValueChange = { strokeWidth = it },
valueRange = 2f..30f,
modifier = Modifier.width(200.dp)
)
}

Spacer(modifier = Modifier.height(12.dp))

// Action buttons: Clear, Undo, Save
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth()
) {
Button(onClick = { pathPoints.clear() }) {
Text("Clear")
}
Button(onClick = {
// Undo last stroke (remove points until previous isNewLine)
for (i in pathPoints.size - 1 downTo 0) {
val point = pathPoints.removeAt(i)
if (point.isNewLine) break
}
}) {
Text("Undo")
}
Button(onClick = {
val width = 1080
val height = 1920
val bitmap = createBitmapFromPath(pathPoints, width, height)
saveBitmapToPictures(context, bitmap, "drawing_${System.currentTimeMillis()}.png")
Toast.makeText(context, "Saved Drawing!", Toast.LENGTH_SHORT).show()
}) {
Text("Save to PNG")
}
}
}
}

OUTPUT:

Jetpack Compose Canvas Touch and Draw and Save to Gallery


Previous Post Next Post

Contact Form