The Canvas
Back on Views, you saw that render is where a View's drawing happens, and that Doodle calls it for you automatically. Now it's time to open up that Canvas and see what it's actually capable of — it's the first of five pages that make up the Rendering section:
- The Canvas (this page) — the coordinate system you're drawing into, and how Doodle redraws efficiently
- Paint & Stroke — filling and outlining shapes with color, gradients, and patterns
- Shapes & Paths — rectangles, circles, polygons, and arbitrary outlines
- Text — drawing, wrapping, styling, and measuring text
- Images — loading and drawing images
Start here, then follow the "Next" link at the bottom of each page — or skip straight to whichever one answers your immediate question.
Let's ease in with the simplest possible example: a View that fills itself with blue.
import io.nacular.doodle.core.View
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Blue
import io.nacular.doodle.drawing.paint
//sampleStart
class RectView: View() {
override fun render(canvas: Canvas) {
canvas.rect(bounds.atOrigin, Blue.paint)
}
}
//sampleEnd
The Canvas you're handed in render plays by one important rule: 0,0 is always the View's own top-left corner, no matter where that View actually sits on screen. You never have to think about your View's position while drawing — only its own little pocket of coordinate space.
The canvas technically stretches beyond your View's bounds in every direction, but anything you draw out there gets quietly clipped away by default.
Need to draw outside your own bounds on purpose? Set clipCanvasToBounds = false in your subclass.
Transformations
You can transform the canvas itself using an AffineTransform — translation, scaling, and rotation, all folded into one matrix. This View flips the canvas horizontally around its midpoint before drawing text:
package rendering
import io.nacular.doodle.core.View
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.text
//sampleStart
class MyView: View() {
override fun render(canvas: Canvas) {
canvas.flipHorizontally(around = width / 2) {
text("hello", color = Black)
}
}
}
//sampleEnd
Going further: 3D
Canvas also stretches into basic 3D, pairing AffineTransforms with a Camera so several Views can share a common 3D space (a folding cube is the classic demo). Most apps never need to go there, so we won't linger on it here — see Transforms in Advanced Topics when you're ready for the full picture, perspective and shared cameras included.
- Demo
- Cube.kt
package io.nacular.doodle.docs.utils
import io.nacular.doodle.core.Camera
import io.nacular.doodle.core.View
import io.nacular.doodle.core.renderProperty
import io.nacular.doodle.drawing.AffineTransform
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.stroke
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.at
import io.nacular.doodle.utils.ChangeObservers
import io.nacular.doodle.utils.ChangeObserversImpl
import io.nacular.measured.units.Angle
import io.nacular.measured.units.times
//sampleStart
class Cube: View() {
private val side = 100.0
private val rect = Rectangle(size = Size(side))
private val stroke = Black.stroke(thickness = 2.0)
var foldAngle by renderProperty(-90 * Angle.degrees) { _,_ -> (changed as ChangeObserversImpl).invoke() }
var canvasCamera by renderProperty(Camera(Point.Origin, 1000.0)) { _,_ -> (changed as ChangeObserversImpl).invoke() }
var canvasTransform by renderProperty(AffineTransform.Identity.translate(z = -side / 2)) { _,_ -> (changed as ChangeObserversImpl).invoke() }
val changed: ChangeObservers<Cube> = ChangeObserversImpl(this)
override fun render(canvas: Canvas) {
var transform = canvasTransform
var faceLocation = Point((width - side) / 2, (height - side) / 2)
drawFace(canvas, faceLocation, transform) // Back
faceLocation += Point(x = side)
transform *= AffineTransform.Identity.rotateY(around = faceLocation, foldAngle)
drawFace(canvas, faceLocation, transform) // Right
faceLocation -= Point(y = side)
drawFace(canvas, faceLocation, transform.rotateX(around = faceLocation + Point(y = side), foldAngle)) // Top
faceLocation += Point(y = 2 * side)
transform *= AffineTransform.Identity.rotateX(around = faceLocation, -foldAngle)
drawFace(canvas, faceLocation, transform) // Bottom
faceLocation += Point(y = side)
transform *= AffineTransform.Identity.rotateX(around = faceLocation, -foldAngle)
drawFace(canvas, faceLocation, transform) // Left
faceLocation += Point(x = side)
transform *= AffineTransform.Identity.rotateY(around = faceLocation, foldAngle)
drawFace(canvas, faceLocation, transform) // Front
}
private fun drawFace(canvas: Canvas, location: Point, transform: AffineTransform) {
canvas.transform(transform, canvasCamera) { rect(rect.at(location), stroke) }
}
}
//sampleEndEfficient rendering
Doodle doesn't blindly redraw everything on every render call — it only touches the parts that actually changed (for Web apps, that means updating only the DOM nodes that changed, not the whole tree). The Timer app below calls rerender every millisecond to keep a clock running, yet only the text itself ever gets touched in the DOM — the surrounding rectangles are left alone:
- View DSL
- Render Property
package timerdsl
import io.nacular.doodle.application.Application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Green
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.rect
import io.nacular.doodle.drawing.stroke
import io.nacular.doodle.drawing.text
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.at
import io.nacular.doodle.geometry.inset
import io.nacular.doodle.layout.constraints.center
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.scheduler.Scheduler
import io.nacular.doodle.time.Clock
import io.nacular.measured.units.Time.Companion.milliseconds
import io.nacular.measured.units.times
//sampleStart
class Timer(display: Display, clock: Clock, scheduler: Scheduler): Application {
init {
display += view {
suggestSize(Size(200))
scheduler.every(1 * milliseconds) {
rerender()
}
render = {
rect(bounds.at(x = 0.0, y = 20.0), color = Green)
rect(bounds.atOrigin.inset(0.5), Red.stroke)
text("${clock.epoch}", color = Black)
}
}
display.layout = constrain(display.first(), center)
display.fill(White.paint)
}
override fun shutdown() {}
}
//sampleEndpackage timerrenderprop
import io.nacular.doodle.application.Application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.View
import io.nacular.doodle.core.renderProperty
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Green
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.rect
import io.nacular.doodle.drawing.stroke
import io.nacular.doodle.drawing.text
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.at
import io.nacular.doodle.layout.constraints.center
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.scheduler.Scheduler
import io.nacular.doodle.time.Clock
import io.nacular.measured.units.Time.Companion.milliseconds
import io.nacular.measured.units.times
//sampleStart
class Timer(display: Display, clock: Clock, scheduler: Scheduler): Application {
init {
display += object: View() {
var time by renderProperty(clock.epoch)
init {
suggestSize(Size(200))
scheduler.every(1 * milliseconds) {
time = clock.epoch
}
}
override fun render(canvas: Canvas) {
canvas.rect(bounds.at(x = 0.0, y = 20.0), color = Green)
canvas.rect(bounds.atOrigin, Red.stroke)
canvas.text("$time", color = Black)
}
}
display.layout = constrain(display.first(), center)
display.fill(White.paint)
}
override fun shutdown() {}
}
//sampleEndDoodle uses Measured for typed units like time and angles — that's what 1 * milliseconds is.
Recap
renderis where a View draws itself, with0, 0always at the View's own top-left corner.- The canvas clips to the View's bounds by default; opt out with
clipCanvasToBounds. - Doodle only touches what actually changed on a redraw, so calling
rerenderoften — once a millisecond, even — is perfectly fine.
Next: on to Paint & Stroke to see what you can actually fill and outline shapes with.