Back to Blog

Kotlin Desktop: Canvas, Path & Pointer Input Drawing App | Lesson 11

Sandy LaneSandy Lane

Video: Kotlin Desktop: Canvas, Path & Pointer Input Drawing App | Lesson 11 by Taught by Celeste AI - AI Coding Coach

Watch full page →

Kotlin Desktop Drawing App: Canvas, Path & Pointer Input

Learn how to create a freehand drawing application using Kotlin Compose Desktop by leveraging the Canvas composable and Path API. This example demonstrates capturing pointer input gestures to draw smooth lines with customizable stroke width and color, along with undo and clear functionality.

Code

import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.Slider
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp

// Data class representing a single freehand stroke
data class DrawingStroke(
  val color: Color,
  val strokeWidth: Float,
  val points: MutableList = mutableListOf()
)

@Composable
@Preview
fun DrawingApp() {
  // State holding all strokes drawn
  val strokes = remember { mutableStateListOf() }
  // Current stroke color and width
  var currentColor by remember { mutableStateOf(Color.Black) }
  var currentStrokeWidth by remember { mutableStateOf(4f) }

  Column(modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)).padding(16.dp)) {
    // Toolbar with color palette, stroke width slider, undo and clear buttons
    Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
      val colors = listOf(Color.Black, Color.Red, Color.Green, Color.Blue, Color.Magenta)
      colors.forEach { color ->
        Box(
          modifier = Modifier
            .size(36.dp)
            .background(color, shape = CircleShape)
            .border(
              width = if (color == currentColor) 3.dp else 1.dp,
              color = if (color == currentColor) Color.DarkGray else Color.LightGray,
              shape = CircleShape
            )
            .padding(4.dp)
            .clickable { currentColor = color }
        )
        Spacer(modifier = Modifier.width(8.dp))
      }
      Spacer(modifier = Modifier.width(16.dp))
      Text("Stroke Width")
      Slider(
        value = currentStrokeWidth,
        onValueChange = { currentStrokeWidth = it },
        valueRange = 1f..20f,
        modifier = Modifier.width(150.dp).padding(horizontal = 8.dp)
      )
      Spacer(modifier = Modifier.width(16.dp))
      Button(onClick = { if (strokes.isNotEmpty()) strokes.removeLast() }) {
        Text("Undo")
      }
      Spacer(modifier = Modifier.width(8.dp))
      Button(onClick = { strokes.clear() }) {
        Text("Clear")
      }
    }
    Spacer(modifier = Modifier.height(16.dp))

    // Drawing canvas
    Box(
      modifier = Modifier
        .fillMaxSize()
        .background(Color.White)
        .pointerInput(Unit) {
          detectDragGestures(
            onDragStart = { offset ->
              // Start a new stroke with initial point
              strokes.add(DrawingStroke(currentColor, currentStrokeWidth, mutableListOf(offset)))
            },
            onDrag = { change, _ ->
              // Append points to the latest stroke as user drags
              strokes.lastOrNull()?.points?.add(change.position)
              change.consume()
            }
          )
        }
    ) {
      Canvas(modifier = Modifier.fillMaxSize()) {
        strokes.forEach { stroke ->
          val path = Path().apply {
            if (stroke.points.isNotEmpty()) {
              moveTo(stroke.points.first().x, stroke.points.first().y)
              stroke.points.drop(1).forEach { point ->
                lineTo(point.x, point.y)
              }
            }
          }
          drawPath(
            path = path,
            color = stroke.color,
            style = Stroke(
              width = stroke.strokeWidth,
              cap = StrokeCap.Round,
              join = StrokeJoin.Round
            )
          )
        }
      }
    }
  }
}

Key Points

  • Use the Canvas composable with the DrawScope to render custom 2D paths representing freehand strokes.
  • Build smooth lines by constructing Paths with moveTo and lineTo from recorded pointer points.
  • Capture pointer input gestures using pointerInput and detectDragGestures to track user drawing.
  • Manage strokes reactively with mutableStateListOf to update the UI as new points are added.
  • Enhance UX with a color palette, adjustable stroke width slider, and undo/clear buttons for editing.