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
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
.
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 regions of these shapes are filled. There are several built-in Paint
types to choose from.
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
3D rendering
Canvas offers basic 3D capabilities through the use of AffineTransform
s and Camera
s. This enables View
s to display content in a shared 3D space.
The following examples has a View that draws a 3D cube. The cube looks like a 3D object even though it is flat on the View's surface. The controls (which are overlaid on the View) let you change the starting transform applied to the cube's back face (which changes the cube's overall transform), the folding angle of each face, and the camera used to create the perspective.
- 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
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
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 = Stroke(thickness = 2.0, fill = Color.Darkgray.paint)
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) }
}
}
//sampleEnd
Filling regions with Paints
Colors
Doodle supports RGBA colors (via the Color
class), for rendering content to the Canvas
via paints like ColorPaint
. Colors can be created explicitly using hex notation, or you can use any of the built-in values (i.e. Red
). Colors can also be derived from others with one of the many utility functions like lighter
, darker
, or by changing opacity
.
package rendering
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.darker
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
fun colors() {
//sampleStart
Color(0xff0000u ) // create red
Color(0xffffffu, opacity = 0.5f) // white with 0.5 opacity
Color.Red // build-in red
Color.Blue opacity 0.5f // blue with 0.5 opacity
Color.Blue.inverted // inverse of blue
Color.Lightgray.darker(0.5f) // darker gray
Color.Blue.paint // paint from a color
//sampleEnd
}
Sometimes it is more effective to work within other color spaces. Doodle has built-in utilities for HSL and HSV colors, though these colors cannot be used directly for rendering. That said, there are many utilities for easily transforming between them and RGBA.
The ColorPicker controls uses HSV internally for example.
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
}
Images
Images can be used to fill regions with a repeating pattern. This paint takes an Image
along with a size and opacity that image should be rendered at. The result is a repeating pattern that tiles the shape being filled.
- Demo
- Code
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.ImagePaint
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.Image
fun imagePaint(image: Image, size: Size, opacity: Float) {
//sampleStart
view {
render = {
rect(bounds.atOrigin, ImagePaint(image, size, opacity))
}
}
//sampleEnd
}
ImagePaint
does not allow any cropping of the provided image. But you can use PatternPaint
to do this.
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
}
Frosted Glass
The FrostedGlassPaint
lets you emulate the look of glass that blurs the contents below it. The paint takes a blurRadius
and an optional Color
.
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.FrostedGlassPaint
import io.nacular.doodle.drawing.opacity
/**
* Example showing how to use [FrostedGlassPaint]s.
*/
fun frostedGlassPaint() {
//sampleStart
view {
render = {
rect(bounds.atOrigin, FrostedGlassPaint(
color = White opacity 0.5f,
blurRadius = 10.0
))
}
}
//sampleEnd
}
Outlining shapes 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, fill = 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")!!
// Star 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
}
Text
All text, whether single or multi-lined, is drawn directly to a Canvas using the text
and wrapped
methods. Text rendering is also explicit, with each API requiring a position within the Canvas
and the Paint
used for filling (and/or Stroke
used for outlining). The following View draw's "hello" at 0,0
using the default font. But it is possible to change the font, letter, word and line spacing (for multi-lined text) as well.
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.text
import io.nacular.doodle.geometry.Point.Companion.Origin
fun text() {
val textView = view {
//sampleStart
render = {
text("hello", Origin, color = Black)
}
//sampleEnd
}
}
You can draw wrapped text using wrapped
, which takes information about the width you'd like the text to occupy. Wrapped text also allows you to specify the line spacing, otherwise, it shares the same inputs as regular text.
Lorem Ipsum is simply dummy text of the printing and typesetting industry. It has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.view
import io.nacular.doodle.docs.utils.controlBackgroundColor
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.width
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.text.TextSpacing
import io.nacular.doodle.utils.Resizer
class MultiLinedTextApp(display: Display): Application {
init {
display += view {
//sampleStart
val text = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. It has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book."
render = {
rect(bounds.atOrigin, fill = White.paint)
wrapped(
text = text,
at = Origin,
width = this.width,
fill = Black.paint,
textSpacing = TextSpacing(wordSpacing = 5.0, letterSpacing = 1.0),
lineSpacing = 1.2f
)
}
//sampleEnd
}.apply {
suggestSize(Size(400, 200))
Resizer(this, movable = false)
}
display.fill(controlBackgroundColor.paint)
display.layout = constrain(display.first()) {
it.width lessEq parent.width - 20
it.center eq parent.center
}
}
override fun shutdown() {}
}
Styled text
You can also draw text (single and multi-line) that is styled using the StyledText
class and its DSLs.
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.view
import io.nacular.doodle.docs.utils.DEFAULT_FONT_FAMILIES
import io.nacular.doodle.docs.utils.controlBackgroundColor
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.Color.Companion.Yellow
import io.nacular.doodle.drawing.FontLoader
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.width
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.text.Target.Background
import io.nacular.doodle.text.TextDecoration
import io.nacular.doodle.text.TextDecoration.Line.Under
import io.nacular.doodle.text.TextDecoration.Style.Wavy
import io.nacular.doodle.text.TextDecoration.Thickness.Absolute
import io.nacular.doodle.text.TextSpacing
import io.nacular.doodle.text.invoke
import io.nacular.doodle.utils.Resizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class StyledTextApp(display: Display, fonts: FontLoader, appScope: CoroutineScope): Application {
init {
appScope.launch {
val bold = fonts {
families = DEFAULT_FONT_FAMILIES
weight = 700
}
display += view {
//sampleStart
val decoration = TextDecoration(
lines = setOf(Under),
color = Red,
thickness = Absolute(1.0),
style = Wavy
)
val text = bold { "Lorem Ipsum" }.." is simply "..Yellow(Background) { "dummy text" }..
" of the printing and typesetting industry. It has been the industry's standard dummy text "..
decoration { "ever since the 1500s" }..
", when an unknown printer took a galley of type and scrambled it to make a type specimen book."
render = {
rect(bounds.atOrigin, fill = White.paint)
wrapped(
text = text,
at = Origin,
width = width,
textSpacing = TextSpacing(wordSpacing = 5.0, letterSpacing = 1.0),
lineSpacing = 1.2f
)
}
//sampleEnd
}.apply {
suggestSize(Size(400, 200))
Resizer(this, movable = false)
}
display.fill(controlBackgroundColor.paint)
display.layout = constrain(display.first()) {
it.width lessEq parent.width - 20
it.center eq parent.center
}
}
}
override fun shutdown() {}
}
Fonts
You can specify a font when drawing text or have Doodle fallback to the default. Fonts can be tricky, since they may not be present on the system at render time. This presents a race-condition for drawing text, since any text drawn with a Font that is simultaneously being loaded (or missing) can be shown in the wrong Font. This is what the FontLoader
is designed to help with.
It presents an asynchronous API for fetching Fonts so the app is explicitly made to deal with this.
You must include the FontModule
(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.FontModule
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import io.nacular.doodle.drawing.FontLoader
import org.kodein.di.instance
class FontLoaderApp(display: Display, fonts: FontLoader): Application {
override fun shutdown() {}
}
fun main() {
//sampleStart
application(modules = listOf(FontModule)) {
FontLoaderApp(display = instance(), fonts = instance())
}
//sampleEnd
}
Doodle uses opt-in modules like this to improve bundle size.
You can use FontLoader
to check the system asynchronously for a given font. This allows you to check for OS fonts, or fonts that have been loaded previously.
package rendering
import io.nacular.doodle.drawing.Font
import io.nacular.doodle.drawing.FontLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
suspend fun systemFont(scope: CoroutineScope, fonts: FontLoader) = coroutineScope {
//sampleStart
// launch a new coroutine for async font lookup
val font: Deferred<Font?> = async {
fonts {
family = "Roboto"
size = 14
weight = 400
}
}
//...
font.await()
//sampleEnd
}
You can also load a font from a file or url using FontLoader
. This is similar to finding a loaded font, but it takes a font file url.
package rendering
import io.nacular.doodle.drawing.Font
import io.nacular.doodle.drawing.FontLoader
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
suspend fun fontUrl(fonts: FontLoader) = coroutineScope {
//sampleStart
async {
// Load this front from the file at "urlToFont"
val font: Font? = fonts("urlToFont") {
family = "Roboto"
size = 14
weight = 400
}
}
//sampleEnd
}
Handling Timeouts
The FontLoader
uses Kotlin's suspend
functions for its async methods. Coroutines are a flexible way of dealing with async/await semantics. You can support timeouts using launch
and canceling the resulting Job after some duration.
package rendering
import io.nacular.doodle.drawing.FontLoader
import io.nacular.doodle.scheduler.Scheduler
import io.nacular.measured.units.Time.Companion.seconds
import io.nacular.measured.units.times
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
suspend fun fontTimeout(fonts: FontLoader, scheduler: Scheduler) = coroutineScope {
//sampleStart
// track loading job
val fontJob = launch {
// assigns the font when the job resolves
val font = fonts {
family = "Roboto"
size = 14
weight = 400
}
}
// Cancel the job after 5 seconds
scheduler.after(5 * seconds) {
if (!fontJob.isCancelled) {
fontJob.cancel()
}
}
//sampleEnd
}
Measuring Text
All text is positioned explicitly when rendered to a Canvas
. This means text alignments like centering etc., require knowledge of the text's size. You can get this via a TextMetrics
, which is available by default for injection into all apps.
This examples shows a View
that draws some centered text based on the calculated size of the text.
package rendering
import io.nacular.doodle.application.Application
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.TextMetrics
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Point
import org.kodein.di.instance
class MyApp(display: Display, textMetrics: TextMetrics): Application {
init {
//sampleStart
display += view {
val hello = "hello"
val textSize = textMetrics.size(hello) // cache text size
render = {
text(
text = hello,
at = Point((width - textSize.width) / 2, (height - textSize.height) / 2),
fill = Black.paint
)
}
}
//sampleEnd
}
override fun shutdown() {}
}
fun launch() {
application {
// TextMetrics is available to inject by default
MyApp(display = instance(), textMetrics = instance())
}
}
The text location could be computed only when the View's size changes, since render can be called even more frequently than that.
Images
Image
s are treated like primitive elements of the rendering pipeline. They are rendered directly to a Canvas
like other shapes and text. This means transformations and other rendering capabilities apply to images as well.
You are able to define the rectangular region within an image that will be put onto the Canvas
, as well as where on the Canvas
that region will be placed. These two values allow you to zoom and scale images as you draw them.
- Image
- Cropped
- Aspect Cropped
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.Modules.Companion.ImageModule
import io.nacular.doodle.application.Modules.Companion.PointerModule
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.center
import io.nacular.doodle.core.height
import io.nacular.doodle.core.view
import io.nacular.doodle.core.width
import io.nacular.doodle.docs.utils.controlBackgroundColor
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.centered
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.utils.Resizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.kodein.di.instance
import org.w3c.dom.HTMLElement
import kotlin.math.min
class ImageApp(
display : Display,
imageLoader: ImageLoader,
appScope : CoroutineScope
): Application {
init {
appScope.launch {
//sampleStart
val image = imageLoader.load("/doodle/images/photo.jpg") ?: return@launch
display.children += view {
render = {
image(image, destination = bounds.atOrigin)
}
Resizer(this)
}
//sampleEnd
display.fill(controlBackgroundColor.paint)
when {
display.size.empty -> display.sizeChanged += { _,_,_ -> setInitialBounds(display, image.size) }
else -> setInitialBounds(display, image.size)
}
}
}
private fun setInitialBounds(display: Display, imageSize: Size) {
with(display.first()) {
if (size.empty && !display.size.empty) {
var w = min(display.width / 2, display.height - 40)
var h = w * imageSize.height / imageSize.width
if (h > display.height - 40) {
h = display.height - 40
w = h * imageSize.width / imageSize.height
}
display.first().suggestBounds(Rectangle(w, h).centered(display.center))
}
}
}
override fun shutdown() {}
companion object {
private val appModules = listOf(PointerModule, ImageModule)
operator fun invoke(root: HTMLElement) = application(root, modules = appModules) {
ImageApp(instance(), instance(), CoroutineScope(SupervisorJob() + Dispatchers.Default))
}
}
}
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.Modules.Companion.ImageModule
import io.nacular.doodle.application.Modules.Companion.PointerModule
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.center
import io.nacular.doodle.core.height
import io.nacular.doodle.core.view
import io.nacular.doodle.core.width
import io.nacular.doodle.docs.utils.controlBackgroundColor
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.centered
import io.nacular.doodle.geometry.div
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.utils.Resizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.kodein.di.instance
import org.w3c.dom.HTMLElement
import kotlin.math.min
class CroppedImageApp(
display : Display,
imageLoader: ImageLoader,
appScope : CoroutineScope
): Application {
init {
appScope.launch {
//sampleStart
val image = imageLoader.load("/doodle/images/photo.jpg") ?: return@launch
display.children += view {
render = {
image(image, source = Rectangle(image.size / 2), destination = bounds.atOrigin)
}
Resizer(this)
}
//sampleEnd
display.fill(controlBackgroundColor.paint)
when {
display.size.empty -> display.sizeChanged += { _,_,_ -> setInitialBounds(display) }
else -> setInitialBounds(display)
}
}
}
private fun setInitialBounds(display: Display) {
with(display.first()) {
if (size.empty && !display.size.empty) {
display.first().suggestBounds(Rectangle(min(display.width / 2, display.height - 80)).centered(display.center))
}
}
}
override fun shutdown() {}
companion object {
private val appModules = listOf(PointerModule, ImageModule)
operator fun invoke(root: HTMLElement) = application(root, modules = appModules) {
CroppedImageApp(instance(), instance(), CoroutineScope(SupervisorJob() + Dispatchers.Default))
}
}
}
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.Modules.Companion.ImageModule
import io.nacular.doodle.application.Modules.Companion.PointerModule
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.center
import io.nacular.doodle.core.height
import io.nacular.doodle.core.view
import io.nacular.doodle.core.width
import io.nacular.doodle.docs.utils.controlBackgroundColor
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.centered
import io.nacular.doodle.geometry.div
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.image.height
import io.nacular.doodle.image.width
import io.nacular.doodle.utils.Resizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.kodein.di.instance
import org.w3c.dom.HTMLElement
import kotlin.math.min
class AspectCroppedImageApp(
display : Display,
imageLoader: ImageLoader,
appScope : CoroutineScope
): Application {
init {
appScope.launch {
//sampleStart
val image = imageLoader.load("/doodle/images/photo.jpg") ?: return@launch
display.children += view {
val aspect = image.width / image.height
render = {
var h = height
var w = h * aspect
if (w > width) {
w = width
h = w / aspect
}
rect(bounds.atOrigin, fill = (White opacity 0.75f).paint)
image(
image = image,
source = Rectangle(image.size / 2),
destination = Rectangle(w, h).centered(Point(width / 2, height / 2))
)
}
Resizer(this)
}
//sampleEnd
display.fill(controlBackgroundColor.paint)
when {
display.size.empty -> display.sizeChanged += { _,_,_ -> setInitialBounds(display) }
else -> setInitialBounds(display)
}
}
}
private fun setInitialBounds(display: Display) {
with(display.first()) {
if (size.empty && !display.size.empty) {
display.first().suggestBounds(Rectangle(min(display.width / 2, display.height - 40)).centered(display.center))
}
}
}
override fun shutdown() {}
companion object {
private val appModules = listOf(PointerModule, ImageModule)
operator fun invoke(root: HTMLElement) = application(root, modules = appModules) {
AspectCroppedImageApp(instance(), instance(), CoroutineScope(SupervisorJob() + Dispatchers.Default))
}
}
}
You can also control the corner radius, and opacity of the image being drawn.
You load Image
s into your app using the ImageLoader
. This interface provides an async API for fetching images from different sources.
You must include the ImageModule
(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.ImageModule
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import io.nacular.doodle.image.ImageLoader
import org.kodein.di.instance
class ImageLoaderApp(display: Display, images: ImageLoader): Application {
override fun shutdown() {}
}
fun imageLoader() {
//sampleStart
application(modules = listOf(ImageModule)) {
ImageLoaderApp(display = instance(), images = instance())
}
//sampleEnd
}
Doodle uses opt-in modules like this to improve bundle size.
ImageLoader
provides APIs for loading images from various sources, like urls, file-paths, and LocalFiles
that are obtained during drag-drop or via a FileSelector.
The following examples shows how loading works. Notice that ImageLoader.load
returns Image?
, which is null
when the image fails to load for some reason. Fetching is also async, so it must be done from a suspend
method or a CoroutineScope
.
package rendering
import io.nacular.doodle.image.Image
import io.nacular.doodle.image.ImageLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
fun loadImage(imageLoader: ImageLoader, scope: CoroutineScope) {
//sampleStart
scope.launch {
val image: Image? = imageLoader.load("some_image_path")
// won't get here until load resolves
image?.let {
// ...
}
}
//sampleEnd
}
See here for an example of how you might handle time-outs.
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.
1743035805386 ms
- 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 {
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), Stroke(Red))
text("${clock.epoch}", color = Black)
}
}
}
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 {
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, Stroke(Red))
canvas.text("$time", color = Black)
}
}
}
override fun shutdown() {}
}
//sampleEnd
Doodle uses Measured for time, angles etc.