UI components
Doodle has several UI components in the controls
library that range from the simple (buttons, text-fields), to the complex (like lists and carousels). Below is a selection of the most common ones.
You will need to add the Controls
library to your app's dependencies.
- Kotlin
- Groovy
build.gradle.kts
dependencies {
implementation ("io.nacular.doodle:controls:$doodleVersion")
}
build.gradle
//sampleStart
dependencies {
implementation "io.nacular.doodle:controls:$doodle_version"
}
//sampleEnd
Label
Holds and displays text with support for basic styling.
- Demo
- Usage
package label
import io.nacular.doodle.controls.text.Label
//sampleStart
val label = Label("Some Text")
//sampleEnd
Requires a LabelBehavior
to render. This example uses basicLabelBehavior
Labels can also have StyledText
, word wrapping, vertical and horizontal alignment, and change how their letters and lines are spaced.
- Demo
- Usage
package label
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.Yellow
import io.nacular.doodle.drawing.Font
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.invoke
import io.nacular.doodle.utils.Dimension.Height
import io.nacular.doodle.utils.TextAlignment.Start
fun example(bold: Font) {
//sampleStart
val styledLabel = Label(
bold("Lorem Ipsum").." is simply "..Yellow("dummy text", Background)..
" of the printing and typesetting industry. It has been the industry's standard dummy text "..
TextDecoration(setOf(Under), Red, Wavy) ("ever since the 1500s")..
", when an unknown printer took a galley of type and scrambled it to make a type specimen book."
).apply {
width = 250.0
fitText = setOf(Height)
wrapsWords = true
lineSpacing = 1f
textAlignment = Start
letterSpacing = 0.0
}
//sampleEnd
}
Requires a LabelBehavior
to render. This example uses basicLabelBehavior
TextField
Provides simple (un-styled) text input that is highly customizable. You can specify each field's purpose
as well to enable platform-specific treatment for things like email, numbers, telephone, etc..
- Demo
- Usage
package textfield
import io.nacular.doodle.controls.text.TextField
import io.nacular.doodle.utils.Dimension.Height
import io.nacular.doodle.utils.Dimension.Width
//sampleStart
val textField = TextField().apply {
mask = '*'
fitText = setOf(Width, Height)
borderVisible = false
// purpose = Email
}
//sampleEnd
Requires a TextFieldBehavior
. The module nativeTextFieldBehavior()
provides one.
TextFields can also be customized using NativeTextFieldBehaviorModifier
. See the code sample for how this is achieved.
- Demo
- Usage
package io.nacular.doodle.docs.utils
import io.nacular.doodle.animation.Animation
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.animation.invoke
import io.nacular.doodle.animation.transition.easeInOutCubic
import io.nacular.doodle.animation.tweenFloat
import io.nacular.doodle.controls.text.TextField
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.Transparent
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.lerp
import io.nacular.doodle.drawing.lighter
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.theme.native.NativeTextFieldBehaviorModifier
import io.nacular.doodle.utils.autoCanceling
import io.nacular.doodle.utils.observable
import io.nacular.measured.units.Time.Companion.milliseconds
import io.nacular.measured.units.times
//sampleStart
class CustomTextFieldBehavior(textField: TextField, animate: Animator): NativeTextFieldBehaviorModifier {
var valid by observable(true) { _, _ ->
textField.rerender()
}
private var animation: Animation<Float>? by autoCanceling()
private var animationProgress by observable(1f) { _, _ ->
textField.rerenderNow()
}
init {
textField.acceptsThemes = false
textField.borderVisible = false
textField.backgroundColor = Transparent
textField.focusChanged += { _,_,_ ->
animation = animate(0f to 1f, tweenFloat(easeInOutCubic, 250 * milliseconds)) { animationProgress = it }
}
}
override fun install(view: TextField) {
super.install(view)
view.enabledChanged += { _,_,_ ->
view.rerender()
}
}
override fun renderBackground(textField: TextField, canvas: Canvas) {
val startX = if (textField.hasFocus) textField.width / 2 * (1 - animationProgress) else 0.0
val endX = if (textField.hasFocus) textField.width / 2 * (1 + animationProgress) else textField.width
val color = (if (valid) Black else Red).let { when {
!textField.enabled -> it.lighter()
else -> it
} }
val thickColor = when {
!textField.hasFocus -> lerp(color, color opacity 0f, animationProgress)
else -> color
}
canvas.line(start = Point(startX, textField.height - 2.0), end = Point(endX, textField.height - 2.0), Stroke(thickness = 1.0, fill = thickColor.paint))
canvas.line(start = Point(0.0, textField.height - 1.0), end = Point(textField.width, textField.height - 1.0), Stroke(thickness = 1.0, fill = color.paint ))
}
}
//sampleEnd
package textfield
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.controls.text.TextField
import io.nacular.doodle.docs.utils.CustomTextFieldBehavior
import io.nacular.doodle.theme.native.NativeTextFieldStyler
fun create(animate: Animator, textFieldStyler: NativeTextFieldStyler) {
//sampleStart
// Animator can be injected into app when Animator module is used
// NativeTextFieldStyler can be injected into app when nativeTextFieldBehavior module is used
val textField = TextField().apply {
acceptsThemes = false
behavior = textFieldStyler(this, CustomTextFieldBehavior(this, animate))
}
//sampleEnd
}
NativeTextFieldStyler
is available whenever the nativeTextFieldBehavior
(Web, Desktop) module is included.
See Animations for more details on how to incorporate them into your app.
PushButton
A component that triggers an action when pressed; usually with the pointer or keyboard.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.buttons.PushButton
//sampleStart
val button = PushButton("BUTTON").apply {
fired += {
println("Hey! That Hurt!")
}
}
//sampleEnd
There are several types of buttons available, including ToggleButton
, CheckBox
, RadioButton
, etc.. Rendering requires a Behavior
<Button>
. NativeTheme
(Web, Desktop) and BasicTheme
provide versions.
ToggleButton
A button component that toggles between 2 states when pressed; usually with the pointer or keyboard.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.buttons.ToggleButton
//sampleStart
val toggleButton = ToggleButton("BUTTON").apply {
selectedChanged += { _,_,selected ->
println("Selected: $selected")
}
}
//sampleEnd
CheckBox
A toggle component that represents an on/off state and is triggered when pressed; usually with the pointer or keyboard.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.buttons.CheckBox
//sampleStart
val checkbox = CheckBox("CHECKBOX").apply {
selectedChanged += { _,_,new ->
println("Checkbox selected: $new, indeterminate: $indeterminate")
}
}
//sampleEnd
There are several types of buttons available, including ToggleButton
, CheckBox
, RadioButton
, etc.. Rendering requires a Behavior
<Button>
. NativeTheme
(Web, Desktop) and BasicTheme
provide versions.
RadioButton
A toggle component that represents an on/off state and is triggered when pressed; usually with the pointer or keyboard. RadioButtons are typically used in lists with ButtonGroup
to represent the selection of a single item from this list.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.buttons.RadioButton
//sampleStart
val radioButton = RadioButton("RADIO BUTTON").apply {
selectedChanged += { _,_,new ->
println("Radio selected: $new")
}
}
//sampleEnd
There are several types of buttons available, including ToggleButton
, CheckBox
, RadioButton
, etc.. Rendering requires a Behavior
<Button>
. NativeTheme
(Web, Desktop) and BasicTheme
provide versions.
Switch
A toggle component that triggers an action when selected; usually with the pointer or keyboard.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.buttons.Switch
import io.nacular.doodle.geometry.Size
//sampleStart
val switch = Switch().apply {
size = Size(50, 30)
selectedChanged += { _,_,new ->
println("Switch selected: $new")
}
}
//sampleEnd
Switches are just ToggleButton
s and can therefore be styled using any Behavior
<Button>
. BasicTheme
provides one via basicSwitchBehavior
.
HyperLink
Control that opens a url when triggered.
- Demo
- Usage
HyperLink
s are also fully customizable with NativeHyperLinkStyler
. This lets you use an arbitrary behavior, while retaining the core functionality to open urls. See the code sample for how this is achieved.
- Demo
- Usage
package hyperlink
import io.nacular.doodle.controls.buttons.Button
import io.nacular.doodle.controls.buttons.HyperLink
import io.nacular.doodle.controls.theme.CommonTextButtonBehavior
import io.nacular.doodle.core.Behavior
import io.nacular.doodle.core.Icon
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.text.TextDecoration.Companion.UnderLine
import io.nacular.doodle.text.invoke
import io.nacular.doodle.theme.native.NativeHyperLinkStyler
import kotlin.math.max
fun example(linkStyler: NativeHyperLinkStyler, textMetrics: TextMetrics, searchIcon: Icon<Button>) {
//sampleStart
val styledHyperLink = HyperLink(url = "https://www.google.com", text = "Google Search").apply {
val textSize = textMetrics.size(text, font)
val iconSize = searchIcon.size(this)
this.icon = searchIcon
this.size = Size(textSize.width + iconTextSpacing + iconSize.width, max(textSize.height, iconSize.height))
this.font = font
this.acceptsThemes = false
this.behavior = linkStyler(this, object: CommonTextButtonBehavior<HyperLink>(textMetrics) {
override fun render(view: HyperLink, canvas: Canvas) {
// Custom text styling, with underline on pointer-over
val styledText = Color.Red { font(text) }.let {
if (view.model.pointerOver) UnderLine { it } else it
}
canvas.text(styledText, at = textPosition(view))
searchIcon.render(view, canvas, iconPosition(view, text, searchIcon))
}
}) as Behavior<Button>
}
//sampleEnd
}
NativeHyperLinkStyler
is available whenever the nativeHyperLinkBehavior
(Web, Desktop) module is included.
FileSelector
A toggle component that triggers an action when selected; usually with the pointer or keyboard. The files (a list of LocalFile
s that) selected by the user are available as an event via the filesLoaded
property.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.files.FileSelector
import io.nacular.doodle.geometry.Size
//sampleStart
val fileSelector = FileSelector().apply {
size = Size(200, 40)
filesLoaded += { _,_,new ->
println("files loaded: $new")
}
}
//sampleEnd
FileSelectors can also be customized using NativeFileSelectorStyler
. See the code sample for how this is achieved.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.files.FileSelector
import io.nacular.doodle.controls.files.FileSelectorBehavior
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.system.Cursor.Companion.Pointer
//sampleStart
class CustomFileSelectorBehavior(textMetrics: TextMetrics): FileSelectorBehavior {
private val prompt = "Choose File"
private val defaultFileText = "No file chosen"
private val textSize = textMetrics.size(prompt)
private val inset = 10
private val lineOffset = textSize.width + 2 * inset
private val strokeColor = Lightgray
private val strokeThickness = 2.0
private val stroke = Stroke(strokeColor, strokeThickness)
override fun install(view: FileSelector) {
super.install(view)
view.cursor = Pointer
view.filesLoaded += { _,_,_ -> view.rerender() }
view.enabledChanged += { _,_,_ -> view.rerender() }
}
override fun render(view: FileSelector, canvas: Canvas) {
val textFill = if (view.enabled) Black.paint else strokeColor.paint
canvas.rect(view.bounds.atOrigin.inset(strokeThickness / 2), radius = 5.0, stroke)
if (view.width > lineOffset + strokeThickness) {
canvas.line(Point(lineOffset, strokeThickness), Point(lineOffset, view.height - strokeThickness), stroke)
}
val files = view.files.joinToString(", ") { it.name }.takeIf { it.isNotBlank() } ?: defaultFileText
val textY = (view.height - textSize.height) / 2
canvas.text(prompt, view.font, Point(inset, textY), textFill)
canvas.text(files, view.font, Point(lineOffset + strokeThickness + inset, textY), textFill)
}
}
//sampleEnd
package controls
import io.nacular.doodle.controls.files.FileSelector
import io.nacular.doodle.controls.files.FileSelectorBehavior
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.system.Cursor.Companion.Pointer
import io.nacular.doodle.theme.native.NativeFileSelectorStyler
fun usage(textMetrics: TextMetrics, fileSelectorStyler: NativeFileSelectorStyler) {
//sampleStart
val fileSelector = FileSelector().apply {
acceptsThemes = false
behavior = fileSelectorStyler(this, CustomFileSelectorBehavior(textMetrics))
}
//sampleEnd
}
NativeFileSelectorStyler
is available whenever the nativeFileSelectorBehavior
(Web, Desktop) module is included.
Photo
Images in Doodle are not Views, they are more like text, in that you render them directly to a Canvas. The Photo component provides a simple wrapper around an Image.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.Photo
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.Image
fun usage(image: Image) {
//sampleStart
val photo = Photo(image).apply {
size = Size(100, 200)
}
//sampleEnd
}
LazyPhoto
This control relies on experimental Coroutine features.
LazyPhoto
is like Photo, except it takes a Deferred<Image>
instead of an Image
. This allows apps to map loading images to Views even if those images are still pending. LazyPhoto also offers full customization of how it renders during the loading state. You provide a lambda that is called for all rendering while it is pending.
You can go a step further and animate the loading state using Animator
and re-rendering the LazyPhoto
when new frames are needed to be displayed. This app shows how you might achieve this.
- Demo
- Usage
package controls
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.animation.RepetitionType.Reverse
import io.nacular.doodle.animation.invoke
import io.nacular.doodle.animation.loop
import io.nacular.doodle.animation.transition.easeInOutCubic
import io.nacular.doodle.animation.tweenColor
import io.nacular.doodle.controls.LazyPhoto
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.lighter
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.image.Image
import io.nacular.doodle.utils.observable
import io.nacular.measured.units.Time.Companion.seconds
import io.nacular.measured.units.times
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
@OptIn(ExperimentalCoroutinesApi::class)
class LazyPhoto(image: Deferred<Image>, animate: Animator) {
//sampleStart
val photo: LazyPhoto = LazyPhoto(
pendingImage = image,
initialized = { animation.cancel() }
) {
// custom rendering during load
rect(Rectangle(size = size), fill = color.paint)
}
val baseColor = Lightgray
var color by observable(baseColor) { _,_ -> photo.rerenderNow() }
val animation = animate(
baseColor to baseColor.lighter(0.5f),
loop(tweenColor(easeInOutCubic, 1.5 * seconds), type = Reverse)
) {
// Animate color used for loading state
color = it
}
//sampleEnd
}
ProgressBar
Represents a value within a specified range that usually indicates progress toward some goal. It provides notifications when its value or range changes. Specify a range by passing a ClosedRange
or ConfinedValueModel
in the constructor.
ProgressBar is a specialization of ProgressIndicator
, which should be used for more generalized progress display (i.e. circular)
- Horizontal
- Vertical
- Usage
package controls
import io.nacular.doodle.controls.ProgressBar
import io.nacular.doodle.utils.Orientation.Vertical
//sampleStart
// creates bars with ranges form 0 - 100
val horizontal = ProgressBar( )
val vertical = ProgressBar(orientation = Vertical)
//sampleEnd
Rendering requires a Behavior
<ProgressBar>
. BasicTheme
provides one.
ProgressIndicators can also take different shapes. Here's an example that uses basicCircularProgressIndicatorBehavior
.
- Demo
- Usage
package controls
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.application
import io.nacular.doodle.controls.ProgressIndicator
import io.nacular.doodle.core.Display
import io.nacular.doodle.docs.apps.CircularProgressApp
import io.nacular.doodle.docs.utils.BlueColor
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicCircularProgressIndicatorBehavior
import org.kodein.di.instance
//sampleStart
fun main() {
// include basicCircularProgressIndicatorBehavior
application(modules = listOf(
basicCircularProgressIndicatorBehavior(foreground = BlueColor.paint, thickness = 5.0)
)) {
CircularProgressApp(display = instance(), themeManager = instance(), theme = instance())
}
}
class CircularProgressApp(display: Display, themeManager: ThemeManager, theme: Theme): Application {
init {
// Theme with basicCircularProgressIndicatorBehavior
themeManager.selected = theme
val circularProgressIndicator = object: ProgressIndicator() {
init {
size = Size(100, 100)
progress = 0.25
accessibilityLabel = "circular progress widget"
}
}
// ...
}
override fun shutdown() {}
}
//sampleEnd
This one draws a path using PathProgressIndicatorBehavior
.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.ProgressIndicator
import io.nacular.doodle.docs.utils.BlueColor
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.LinearGradientPaint
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.PathMetrics
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.path
import io.nacular.doodle.theme.PathProgressIndicatorBehavior
fun usage(pathMetrics: PathMetrics) {
//sampleStart
val pathProgress = object: ProgressIndicator() {
init {
size = Size(200, 100)
progress = 0.25
behavior = PathProgressIndicatorBehavior(
pathMetrics, // injected
path = path("M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80")!!,
foreground = LinearGradientPaint(Black, BlueColor, Origin, Point(width, 0.0)),
foregroundThickness = 5.0,
background = Lightgray.paint,
backgroundThickness = 5.0
)
}
}
//sampleEnd
}
Slider
Slider
holds a strongly typed value within a specified range and allow the user to change the value. It provides notifications when
its value or range changes. Specify a range by passing a ClosedRange
or ConfinedValueModel
in the constructor.
You can also confine the values to a predefined set within the range by specifying the ticks
count and setting
snapToTicks
to true
. This will pin the slider values to an evenly spaced set of points along its range.
- Horizontal
- Vertical
- Usage
package controls
import io.nacular.doodle.controls.range.Slider
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.utils.Orientation.Vertical
//sampleStart
val horizontalSlider = Slider(0.0 .. 1.0).apply {
size = Size(200, 15)
// ticks = 10
// snapToTicks = true
}
val verticalSlider = Slider(0 .. 100, orientation = Vertical).apply {
size = Size(15, 200)
}
//sampleEnd
Rendering requires a Behavior<Slider>
. BasicTheme
provides one.
All Sliders (including CircularSlider, RangeSlider, and CircularRangeSlider) are strongly typed. Which means you can create Integer
sliders that snap to each integer value. Therefore, it is not necessary to specify ticks
and snapToTicks
to ensure they only land on whole numbers.
It is possible to still restrict their range further using these properties however. Then, they will only take on integer values that match the tick count.
Non-linearity
Sliders are linear by default, which means a change in their position translates to a linear change in their value. There are cases however, when it makes sense to have a slider's value change in a non-linear way. You can do this by providing a function that maps values between the slider's input and output spaces. These values are all within the [0-1] domain, and work very similarly to easing functions used for animations. The big difference is they have two forms: f(x) and f^-1(x).
This examples shows two sliders that control the rectangle's opacity. One uses a logarithmic function while the other is the typical linear slider. Notice the difference in how quickly the opacity changes in the beginning when adjusting the logarithmic slider.
- Non-linearity
- Usage
package controls
import io.nacular.doodle.controls.range.InvertibleFunction
import io.nacular.doodle.controls.range.Slider
import kotlin.math.log
import kotlin.math.pow
//sampleStart
/**
* Logarithmic function and inverse https://www.desmos.com/calculator/qq59ey0bub
*/
private object LogFunction: InvertibleFunction {
override fun invoke (value: Float) = log((10f - 1) * value + 1, 10f)
override fun inverse(value: Float) = (10f.pow(value) - 1)/(10 - 1)
}
val logarithmicSlider = Slider(0.0 .. 1.0, function = LogFunction)
//sampleEnd
All slider types support custom functions to make them non-linear.