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

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Canvas, Path, and Pointer Input: Build a Drawing App

Canvas for direct rendering. pointerInput plus detectDragGestures for capturing strokes. A mutableStateListOf<DrawingStroke> for undo. Free-hand drawing in Compose Desktop.

Today's app is a small drawing program. Pick a colour, drag on the canvas to draw a stroke, change the brush width, undo, clear. The new pieces are direct rendering with Canvas and gesture handling with pointerInput.

What we are building

A 900×600 window with:

  • A toolbar with a colour palette, a stroke-width slider, undo and clear buttons.
  • A canvas filling the rest of the window — drag the mouse to paint.
  • The currently-drawn stroke renders live; on release it commits to the stroke list.

Project layout

src/main/kotlin/
├── Main.kt
├── DrawingApp.kt        # toolbar + state
├── DrawingCanvas.kt     # Canvas + pointer input
└── Stroke.kt            # DrawingStroke data class + palette

Stroke.kt: the data

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color

data class DrawingStroke(
  val points: List<Offset>,
  val color: Color,
  val width: Float,
)

val palette = listOf(
  Color.White,
  Color(0xFFE57373),  // red
  Color(0xFFFFB74D),  // orange
  Color(0xFFFFF176),  // yellow
  Color(0xFF81C784),  // green
  Color(0xFF64B5F6),  // blue
  Color(0xFFBA68C8),  // purple
)

A stroke is a sequence of points plus styling. Offset is Compose's 2D coordinate (x, y as Float).

DrawingCanvas.kt: the drawing surface

@Composable
fun DrawingCanvas(
  strokes: List<DrawingStroke>,
  currentStroke: DrawingStroke?,
  onStrokeStart: (Offset) -> Unit,
  onStrokeDrag: (Offset) -> Unit,
  onStrokeEnd: () -> Unit,
) {
  Canvas(
    modifier = Modifier
      .fillMaxSize()
      .pointerInput(Unit) {
        detectDragGestures(
          onDragStart = { offset -> onStrokeStart(offset) },
          onDrag = { change, _ -> onStrokeDrag(change.position) },
          onDragEnd = { onStrokeEnd() },
        )
      },
  ) {
    // Draw all completed strokes
    strokes.forEach { stroke ->
      drawStroke(stroke)
    }
    // Draw the in-progress stroke
    currentStroke?.let { drawStroke(it) }
  }
}

fun DrawScope.drawStroke(stroke: DrawingStroke) {
  if (stroke.points.size < 2) return
  for (i in 0 until stroke.points.lastIndex) {
    drawLine(
      color = stroke.color,
      start = stroke.points[i],
      end = stroke.points[i + 1],
      strokeWidth = stroke.width,
      cap = StrokeCap.Round,
    )
  }
}

Two halves: the gesture handling and the drawing.

pointerInput + detectDragGestures

.pointerInput(Unit) {
  detectDragGestures(
    onDragStart = { offset -> onStrokeStart(offset) },
    onDrag = { change, _ -> onStrokeDrag(change.position) },
    onDragEnd = { onStrokeEnd() },
  )
}

Modifier.pointerInput(key) { ... } sets up gesture detection. The key controls when to restart the gesture detection — Unit means "set it up once, reuse it." If you needed to re-bind based on state, you'd pass that state as the key.

detectDragGestures is one of several built-in gesture detectors. It fires three callbacks:

  • onDragStart(offset) — finger/cursor goes down. offset is the position relative to this widget.
  • onDrag(change, dragAmount) — pointer moves while down. change.position is the new position; dragAmount is the delta from the previous frame.
  • onDragEnd() — pointer lifts.

We use change.position (absolute) rather than dragAmount (delta) so the parent can directly append the cursor location to the stroke's point list.

Other gesture detectors: detectTapGestures, detectTransformGestures (pinch/zoom/rotate), awaitPointerEventScope for fully custom event handling.

Canvas

Canvas(modifier = Modifier.fillMaxSize().pointerInput(...)) {
  // DrawScope: drawing functions
  strokes.forEach { drawStroke(it) }
  currentStroke?.let { drawStroke(it) }
}

Canvas { ... } gives you a DrawScope — the same API used by Compose internally. Draw lines, rects, paths, images, text, all via simple function calls:

  • drawLine(color, start, end, strokeWidth, cap)
  • drawRect(color, topLeft, size)
  • drawCircle(color, radius, center)
  • drawPath(path, color, style)
  • drawText(textMeasurer, text, ...)
  • drawImage(image, topLeft)

StrokeCap.Round makes line endpoints rounded — the natural look for a brush.

drawStroke

fun DrawScope.drawStroke(stroke: DrawingStroke) {
  if (stroke.points.size < 2) return
  for (i in 0 until stroke.points.lastIndex) {
    drawLine(
      color = stroke.color,
      start = stroke.points[i],
      end = stroke.points[i + 1],
      strokeWidth = stroke.width,
      cap = StrokeCap.Round,
    )
  }
}

For each consecutive pair of points, draw a line segment. With enough points captured per second, the segments form a smooth curve.

For higher-fidelity strokes, build a Path and use drawPath — Compose's path API supports cubic and quadratic curves, which give smoother results than line segments. We keep it simple here.

DrawingApp.kt: state and toolbar

@Composable
fun DrawingApp() {
  val strokes = remember { mutableStateListOf<DrawingStroke>() }
  var currentStroke by remember { mutableStateOf<DrawingStroke?>(null) }
  var selectedColor by remember { mutableStateOf(Color.White) }
  var strokeWidth by remember { mutableStateOf(4f) }

  Column(modifier = Modifier.fillMaxSize().background(Color(0xFF1E1E1E))) {
    // toolbar
    Row(...) {
      // color palette
      for (color in palette) {
        Box(
          modifier = Modifier.size(32.dp).clip(CircleShape).background(color)
            .then(if (color == selectedColor) Modifier.border(3.dp, Color.White, CircleShape) else Modifier.border(1.dp, Color.Gray, CircleShape))
            .clickable { selectedColor = color }
        )
      }
      // width slider
      Slider(value = strokeWidth, onValueChange = { strokeWidth = it }, valueRange = 4f..32f)
      // undo + clear
      IconButton(onClick = { if (strokes.isNotEmpty()) strokes.removeLast() }) { Icon(Icons.AutoMirrored.Default.Undo, ...) }
      IconButton(onClick = { strokes.clear() }) { Icon(Icons.Default.Delete, ...) }
    }

    DrawingCanvas(
      strokes = strokes,
      currentStroke = currentStroke,
      onStrokeStart = { offset ->
        currentStroke = DrawingStroke(points = listOf(offset), color = selectedColor, width = strokeWidth)
      },
      onStrokeDrag = { offset ->
        currentStroke = currentStroke?.copy(points = currentStroke!!.points + offset)
      },
      onStrokeEnd = {
        currentStroke?.let { strokes.add(it) }
        currentStroke = null
      },
    )
  }
}

State machine:

  • strokes — completed strokes. mutableStateListOf for reactivity.
  • currentStroke — the stroke being drawn right now. null between strokes.
  • selectedColor / strokeWidth — current brush settings.

Stroke lifecycle:

  • Start: build a fresh DrawingStroke with one point and the current brush settings.
  • Drag: copy the current stroke with the new point appended.
  • End: append currentStroke to strokes, clear currentStroke.

The current stroke is drawn live on every drag (because currentStroke?.let { drawStroke(it) } runs every frame), so the user sees their brush in real time.

Undo and clear

IconButton(onClick = { if (strokes.isNotEmpty()) strokes.removeLast() }) { ... }
IconButton(onClick = { strokes.clear() }) { ... }

Undo pops the last stroke. Clear empties the list. Both rely on mutableStateListOf's reactivity — the canvas re-renders without the removed strokes.

For redo, you'd maintain a separate redoStack and push popped strokes onto it. We skip redo for brevity.

Color palette UI

Box(
  modifier = Modifier
    .size(32.dp)
    .clip(CircleShape)
    .background(color)
    .then(
      if (color == selectedColor) Modifier.border(3.dp, Color.White, CircleShape)
      else Modifier.border(1.dp, Color.Gray, CircleShape)
    )
    .clickable { selectedColor = color }
)

Modifier.then(otherModifier) chains conditionally. The selected colour gets a thick white border; unselected ones get a thin grey border. Click sets the selection.

Smoothing

Strokes drawn from raw cursor positions look slightly jagged — the cursor gives you ~60 samples per second, and at fast drag speeds the gaps between samples are visible.

Three improvements you could add:

  1. Catmull-Rom spline interpolation between captured points produces smoother curves.
  2. Bezier paths via Path.cubicTo give the same effect with less math.
  3. Pressure-aware width for stylus input — change.pressure is part of PointerInputChange.

We keep the line-segment version because it's simple and fast.

Saving the drawing

To save the canvas as an image:

val composeImage = renderComposeScene { /* the canvas */ }
val pngBytes = composeImage.encodeToData(EncodedImageFormat.PNG)?.bytes
File("drawing.png").writeBytes(pngBytes)

Compose's image-rendering APIs let you snapshot any Composable to a bitmap. For a drawing app, "Save as PNG" is one button away.

Common mistakes

Mutating the strokes list inside the canvas drawing. Canvas { strokes.add(...) } — the drawing block is for drawing, not state mutation. Use the gesture callbacks for state changes.

Forgetting pointerInput(Unit). Without setting up pointerInput, no gestures fire. The Canvas just sits there.

Drawing strokes with drawPath and not setting style = Stroke(width). drawPath fills by default. To draw an outline, pass style = Stroke(strokeWidth).

Storing positions as Pair<Float, Float> instead of Offset. Compose's drawing APIs all take Offset. Avoid the conversion.

Re-creating the gesture lambda on every recomposition. The pointerInput(Unit) { ... } block is keyed on Unit, so it's set up once. If you keyed it on a state that changes frequently, the gesture detector would re-init every frame — slow and gesture-losing.

What's next

Lesson 12: animations and spring physics. A dashboard with animated transitions, spring-physics motion, and smooth state changes. The bridge from "static UI" to "feels alive."

Recap

Canvas { ... } for direct rendering. Modifier.pointerInput(Unit) { detectDragGestures(...) } for drag detection. Stroke = list of points + styling. Live preview by drawing currentStroke separately from committed strokes. mutableStateListOf for the stroke list. Round line caps for natural brush feel.

Next lesson: animations and spring physics.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.