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:
- Jetpack Compose UI building
- Gesture detection with detectDragGestures
- State management with remember and mutableStateListOf
- Canvas drawing using Compose and traditional android.graphics.Canvas
- 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")
}
}
}
}