Best Tutorial for AndroidDevelopers

Android App Development

Stay ahead with the latest tools, trends, and best practices in Android development

Jetpack Compose Canvas Freehand Drawing

Jetpack Compose Canvas Freehand Drawing - Responsive Blogger Template
Touch and Draw and Save to Gallery: Implementing Freehand Drawing in Jetpack Compose Canvas

Jetpack Compose Canvas Freehand Drawing 

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 Jetpack Compose Freehand 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 Complete Code:

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
Sandeep Kumar - Android Developer

About the Author

Sandeep Kumar is an Android developer and educator who writes beginner-friendly Jetpack Compose tutorials on CodingBihar.com. His focus is on clean UI, Material Design 3, and real-world Android apps.

SkillDedication

— Python High Level Programming Language- Expert-Written Tutorials, Projects, and Tools—

Coding Bihar

Welcome To Coding BiharπŸ‘¨‍🏫