Rendering
Doodle automatically manages rendering of Views
, and this covers almost all use-cases. Each View
draws its content to a Canvas provided during calls to the render
method. This either presents the View's contents on the screen for the first time or updates them on subsequent calls.
Doodle calls render
whenever a View
needs a visual update, which includes changes to its size
or it becoming visible
. This means you do not need to call render
yourself. But there might be cases when you want to request that a View re-renders. You can do this by calling rerender
.
This is an example of a simple View
that draws a rectangle filled with Blue
ColorPaint
that covers its bounds.
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
Efficient rendering
Doodle optimizes rendering to avoid re-applying operations when drawing the same content repeatedly. For example, the Timer
app below renders the epoch time every millisecond. However, Doodle only updates the changing regions (i.e. the DOM for Web apps), which is the text in this case. This reduces the amount of thrash in the DOM for Web apps.
Doodle uses Measured for time, angles etc.
- Demo
- 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.Stroke
import io.nacular.doodle.drawing.rect
import io.nacular.doodle.drawing.text
import io.nacular.doodle.geometry.Size
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 {
size = Size(200)
scheduler.every(1 * milliseconds) {
rerender()
}
render = {
rect(bounds.atOrigin, Stroke(Red))
text("${clock.epoch}", color = Black)
rect(bounds.at(y = 20.0), color = Green)
}
}
}
override fun shutdown() {}
}
//sampleEnd
package 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.Stroke
import io.nacular.doodle.drawing.rect
import io.nacular.doodle.drawing.text
import io.nacular.doodle.geometry.Size
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 {
size = Size(200)
scheduler.every(1 * milliseconds) {
time = clock.epoch
}
}
override fun render(canvas: Canvas) {
canvas.rect(bounds.atOrigin, Stroke(Red))
canvas.text("$time", color = Black)
canvas.rect(bounds.at(y = 20.0), color = Green)
}
}
}
override fun shutdown() {}
}
//sampleEnd
The Canvas
All drawing is done via the Canvas
API, which offers a rich set of operations for geometric shapes, paths, images, and text. It also supports different Paint
types (i.e. ColorPaint
, LinearGradientPaint
, PatternPaint, etc.) for filling regions.
The Canvas
provided to a View
's render
method has a coordinate system that anchors 0,0
on the Canvas
to the View
's origin. The Canvas itself extends in all directions beyond the bounds of the View; but the contents drawn to it will be clipped to the view's bounds by default.
Sub-classes can disable clipping by setting clipCanvasToBounds
= false
.
Filling regions with Paints
Most Views require geometric regions on their Canvas to be filled with some color, gradient, texture, etc.. Therefore, many Canvas APIs take a Paint
parameter that determines how the inner region such shapes are filled. There are several built-in Paint
types to choose from.
Colors
ColorPaint
is the simplest and most common way to fill a region. You can create one using the constructor, or via the paint
extension on any Color
.
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.paint
fun colorPaint(color: Color) {
//sampleStart
view {
render = {
rect(bounds.atOrigin, color.paint)
}
}
//sampleEnd
}
Gradients
You can fill regions with LinearGradientPaint
s, RadialGradientPaint
s and SweepGradientPaint
s paints as well. These take a series of Color
s and stop locations that are turned into a smoothly transitioning gradient across a shape.
- Linear
- Radial
- Sweep
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.GradientPaint.Stop
import io.nacular.doodle.drawing.LinearGradientPaint
import io.nacular.doodle.geometry.Point
/**
* Example showing how to use [LinearGradientPaint]s.
*/
fun linearPaint(color1: Color, color2: Color, point1: Point, point2: Point) {
//sampleStart
view {
render = {
// Simple version with 2 colors
rect(bounds.atOrigin, LinearGradientPaint(
color1,
color2,
point1,
point2
))
}
}
view {
render = {
// Also able to use a list of color stops
rect(bounds.atOrigin, LinearGradientPaint(
listOf(
Stop(color1, 0f ),
Stop(color1, 1f/3),
// ...
),
point1,
point2
))
}
}
//sampleEnd
}
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.GradientPaint.Stop
import io.nacular.doodle.drawing.RadialGradientPaint
import io.nacular.doodle.geometry.Circle
/**
* Example showing how to use [RadialGradientPaint]s.
*/
fun radialPaint(color1: Color, color2: Color, circle1: Circle, circle2: Circle) {
//sampleStart
view {
render = {
// Simple version with 2 colors
rect(bounds.atOrigin, RadialGradientPaint(
color1,
color2,
circle1,
circle2
))
}
}
view {
render = {
// Also able to use a list of color stops
rect(bounds.atOrigin, RadialGradientPaint(
listOf(
Stop(color1, 0f ),
Stop(color1, 1f/3),
// ...
),
circle1,
circle2
))
}
}
//sampleEnd
}
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.GradientPaint.Stop
import io.nacular.doodle.drawing.SweepGradientPaint
import io.nacular.doodle.geometry.Point
import io.nacular.measured.units.Angle
import io.nacular.measured.units.Measure
/**
* Example showing how to use [SweepGradientPaint]s.
*/
fun sweepGradientPaint(color1: Color, color2: Color, center: Point, rotation: Measure<Angle>) {
//sampleStart
view {
render = {
// Simple version with 2 colors
rect(bounds.atOrigin, SweepGradientPaint(
color1,
color2,
center,
rotation
))
}
}
view {
render = {
// Also able to use a list of color stops
rect(
bounds.atOrigin, SweepGradientPaint(
listOf(
Stop(color1, 0f),
Stop(color1, 1f / 3),
// ...
),
center,
rotation
)
)
}
}
//sampleEnd
}
Patterns
Sometimes you need to fill a region with a repeating pattern, like an image or some geometric shapes. This is easy to achieve with ImagePaint
(when an image is all you need) or PatternPaint
, when more sophisticated patterns are needed.
PatternPaint
has a "render" body that provides a powerful and familiar way of creating repeating patterns. You create one by specifying a size
and a paint
lambda, which has access to the full Canvas
APIs.
This app uses StripedPaint
to show how a PatternPaint
can be transformed using AffineTransform2D
, like rotated around its center for example.
- Demo
- Code
package rendering
import io.nacular.doodle.core.View
import io.nacular.doodle.core.renderProperty
import io.nacular.doodle.drawing.AffineTransform.Companion.Identity
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.stripedPaint
import io.nacular.doodle.geometry.Point
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.times
// Doodle's implementation of striped-paint looks something like this
/*
fun stripedPaint(
stripeWidth : Double,
evenRowColor: Color? = null,
oddRowColor : Color? = null,
transform : AffineTransform2D = Identity
): PatternPaint = PatternPaint(Size(if (evenRowColor.visible || oddRowColor.visible) stripeWidth else 0.0, 2 * stripeWidth), transform) {
evenRowColor?.let { rect(Rectangle( stripeWidth, stripeWidth), ColorPaint(it)) }
oddRowColor?.let { rect(Rectangle(0.0, stripeWidth, stripeWidth, stripeWidth), ColorPaint(it)) }
}
*/
//sampleStart
class SomeView: View() {
private val stripeWidth = 20.0
private var paintAngle by renderProperty(0 * degrees)
override fun render(canvas: Canvas) {
val paintCenter = Point(canvas.size.width / 2, canvas.size.height / 2)
canvas.rect(bounds.atOrigin, stripedPaint(
stripeWidth = stripeWidth,
evenRowColor = Red,
oddRowColor = White,
transform = Identity.rotate(around = paintCenter, by = paintAngle)
))
}
}
//sampleEnd
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.AffineTransform2D
import io.nacular.doodle.drawing.PatternPaint
import io.nacular.doodle.geometry.Size
fun patternPaint(size: Size, transform: AffineTransform2D) {
//sampleStart
view {
render = {
rect(bounds.atOrigin, PatternPaint(size, transform) {
// render onto canvas
// rect(..)
})
}
}
//sampleEnd
}
Outlining with Strokes
Regions and curves can be stroked to give them an outline of some sort. The Canvas APIs also take Stroke
parameters that determine how to outline the shape being rendered.
A Stroke
combines the behavior of a Paint (but for the outline only) and adds several properties that govern how the outline is created.
Strokes are centered along the edge of the shape the outline. This means adding a stroke will expand the area needed to render the shape by stroke.thickness / 2
in the x and y direction. Keep this in mind, and shrink shapes accordingly to ensure outlines are not clipped and they appear where you want them to.
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.Stroke.LineCap.Round
import io.nacular.doodle.drawing.Stroke.LineJoint.Miter
fun stroke(paint: Paint) {
//sampleStart
view {
render = {
rect(bounds.atOrigin, stroke = Stroke(
fill = paint,
dashes = doubleArrayOf(10.0, 20.0),
thickness = 2.5,
lineCap = Round,
lineJoint = Miter
))
}
}
//sampleEnd
}
Basic shapes
The Canvas
has APIs for drawing many basic shapes that foundation of most Views. Each shape can be filled with Paint
and outlined with Stroke
s as well.
- Rect
- Poly
- Circle
- Ellipse
- Arc
- Wedge
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.geometry.Rectangle
fun rect(rectangle: Rectangle, paint: Paint, stroke: Stroke) {
//sampleStart
view {
render = {
rect(rectangle, paint, stroke = stroke)
}
}
//sampleEnd
}
A View's bounds is relative to its parent, so you need to do bring it into the View's coordinate space before drawing it to the Canvas. This is easy with bounds.atOrigin
.
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Polygon
import io.nacular.doodle.geometry.inscribed
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.times
fun poly(polygon: Polygon, circle: Circle, paint: Paint, stroke: Stroke) {
// You can also create equilateral polygons by inscribing them
// within circles.
circle.inscribed(8, rotation = 45 * degrees)
//sampleStart
view {
render = {
poly(polygon, fill = paint, stroke = stroke)
}
}
//sampleEnd
}
You can also create equilateral polygons using the EllipseInscribed
function on a Circle
:
circle.inscribed(8, rotation = 45 * degrees)
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.inscribedCircle
fun circle(circle: Circle, rectangle: Rectangle, paint: Paint, stroke: Stroke) {
// You can also create circles based on Rectangles
rectangle.inscribedCircle()
//sampleStart
view {
render = {
circle(circle, fill = paint, stroke = stroke)
}
}
//sampleEnd
}
Circles can be created from Rectangle
s, using the InscribedCircle
method:
rectangle.inscribedCircle()
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.geometry.Ellipse
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.inscribedEllipse
fun ellipse(ellipse: Ellipse, rectangle: Rectangle, paint: Paint, stroke: Stroke) {
// You can also create ellipses based on Rectangles
rectangle.inscribedEllipse()
//sampleStart
view {
render = {
ellipse(ellipse, fill = paint, stroke = stroke)
}
}
//sampleEnd
}
Ellipses can be created from Rectangle
s, using the InscribedEllipse
method:
rectangle.inscribedEllipse()
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.geometry.Point
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.Angle.Companion.radians
import io.nacular.measured.units.times
import kotlin.math.PI
fun arc(center: Point, paint: Paint, stroke: Stroke) {
//sampleStart
view {
render = {
arc(
center = center,
radius = 100.0,
fill = paint,
stroke = stroke,
sweep = 270 * degrees,
rotation = PI * radians
)
}
}
//sampleEnd
}
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.geometry.Point
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.Angle.Companion.radians
import io.nacular.measured.units.times
import kotlin.math.PI
fun wedge(center: Point, paint: Paint, stroke: Stroke) {
//sampleStart
view {
render = {
wedge(
center = center,
radius = 100.0,
fill = paint,
stroke = stroke,
sweep = 270 * degrees,
rotation = PI / 2 * radians
)
}
}
//sampleEnd
}
Paths
Paths a really powerful primitives that let you create arbitrarily complex shapes that can be filled and stroked like those above. This flexibility comes from the fact that paths are fairly opaque components that behave almost like images.
A major difference with Paths is that they do not track their intrinsic size (though this can be obtained using a PathMetrics
). This is a tradeoff that allows them to be created in a very light weight way. Paths can be transformed when rendered to a Canvas
to adjust their size, position, etc.
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.inscribed
import io.nacular.doodle.geometry.path
import io.nacular.doodle.geometry.rounded
fun path(paint: Paint, stroke: Stroke) {
// Path from SVG data
val heart = path("M50,25 C35,0,-14,25,20,60 L50,90 L80,60 C114,20,65,0,50,25")!!
// Start with rounded corners
val star = Circle(
radius = 100.0,
center = Point(100, 100)
).inscribed(5)!!.rounded(radius = 10.0)
//sampleStart
view {
render = {
// transform the Canvas to change path bounds, etc.
path(star, fill = paint, stroke = stroke)
}
}
//sampleEnd
}
You must include the PathModule
(Web, Desktop) in your application in order to use these features.
package rendering
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.Modules.Companion.PathModule
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import io.nacular.doodle.geometry.PathMetrics
import org.kodein.di.instance
class PathApp(display: Display, pathMetrics: PathMetrics): Application {
override fun shutdown() {}
}
fun pathModule() {
//sampleStart
application(modules = listOf(PathModule)) {
PathApp(display = instance(), pathMetrics = instance())
}
//sampleEnd
}
Doodle uses opt-in modules like this to improve bundle size.
There are may ways to create Paths.
package rendering
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Path
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.map
import io.nacular.doodle.geometry.path
import io.nacular.doodle.geometry.toPath
fun pathCreation(start: Point, end: Point, width: Double, height: Double) {
//sampleStart
// Just convert most geometric shapes into them:
Circle().toPath()
Rectangle().toPath()
Rectangle().map { it * 2 }.toPath()
// Use the path builder function:
path(start)
.cubicTo(
Point(width / 2, height / 2),
Point(width / 3, 0),
Point(width / 2, -10)
)
.cubicTo(
end,
Point(width / 2, height + 10),
Point(width * 2/3, height)
).finish() // or close
// Or use a raw String:
val path: Path? = path("...") // returns nullable since given string might be invalid
val imSure = path("...")!!
//sampleEnd
}
Transformations
The Canvas
can also be transformed using any AffineTransform
. These are linear transformation which encapsulate translation, scaling, and rotation in matrices. You can combine these transforms into more complex ones directly, or apply them to a Canvas
in a nested fashion to get similar results
This View
flips the Canvas horizontally around its mid-point and draws some 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