Kotlin Desktop: Canvas, Path & Pointer Input Drawing App | Lesson 11
Video: Kotlin Desktop: Canvas, Path & Pointer Input Drawing App | Lesson 11 by Taught by Celeste AI - AI Coding Coach
Canvas, Path, and Pointer Input: Build a Drawing App
Canvasfor direct rendering.pointerInputplusdetectDragGesturesfor capturing strokes. AmutableStateListOf<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.offsetis the position relative to this widget.onDrag(change, dragAmount)— pointer moves while down.change.positionis the new position;dragAmountis 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.mutableStateListOffor reactivity.currentStroke— the stroke being drawn right now.nullbetween strokes.selectedColor/strokeWidth— current brush settings.
Stroke lifecycle:
- Start: build a fresh
DrawingStrokewith one point and the current brush settings. - Drag: copy the current stroke with the new point appended.
- End: append
currentStroketostrokes, clearcurrentStroke.
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:
- Catmull-Rom spline interpolation between captured points produces smoother curves.
- Bezier paths via
Path.cubicTogive the same effect with less math. - Pressure-aware width for stylus input —
change.pressureis part ofPointerInputChange.
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.