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.TextAlignment.Start
fun example(bold: Font) {
//sampleStart
val styledLabel = Label(
bold { "Lorem Ipsum" }.." is simply "..Yellow(Background) { "dummy text" }..
" 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 {
suggestWidth(250.0)
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 {
suggestSize(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.Companion.Red
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.suggestSize(Size(textSize.width + iconTextSpacing + iconSize.width, max(textSize.height, iconSize.height)))
this.icon = searchIcon
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 = 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 {
suggestSize(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.
- Photo
- 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
Photo(image).apply {
suggestSize(Size(100, 200))
}
//sampleEnd
}
You can easily have a Photo
that preserves its aspect ratio using the preserveAspect
SizeAuditor
.
- Aspect Photo
- Usage
package controls
import io.nacular.doodle.controls.Photo
import io.nacular.doodle.core.View.SizeAuditor.Companion.preserveAspect
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.Image
fun aspectPhoto(image: Image) {
//sampleStart
Photo(image).apply {
sizeAuditor = preserveAspect(width, height)
suggestSize(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 {
suggestSize(Size(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 {
suggestSize(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 {
suggestSize(Size(200, 15))
// ticks = 10
// snapToTicks = true
}
val verticalSlider = Slider(0 .. 100, orientation = Vertical).apply {
suggestSize(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.
CircularSlider
CircularSlider
behaves just like a regular Slider, except it is meant to be a ring. This means it provides notifications when its value or range changes and these can be specified by passing a ClosedRange
or ConfinedValueModel
in the constructor.
Like Slider, 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.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.range.CircularSlider
import io.nacular.doodle.geometry.Size
//sampleStart
val circularSlider = CircularSlider(1 .. 100).apply {
suggestSize(Size(100, 100))
// ticks = 10
// snapToTicks = true
}
//sampleEnd
Rendering requires a Behavior<CircularSlider>
. BasicTheme
provides one.
RangeSlider
RangeSlider
holds a strongly typed inner range within a specified outer range and allow the user to change these values. It provides notifications when its either changes. Specify the ranges by passing ClosedRange
s or a ConfinedRangeModel
in the constructor.
You can also confine the inner range using a ticks
count and setting snapToTicks
to true
, just like regular ranges. This will pin the values of the inner range to an evenly spaced set of points along its range.
- Demo
- Vertical
- Usage
package controls
import io.nacular.doodle.controls.range.RangeSlider
import io.nacular.doodle.geometry.Size
//sampleStart
val rangeSlider = RangeSlider(value = 10 .. 30, limits = 1 .. 100).apply {
suggestSize(Size(100))
// ticks = 10
// snapToTicks = true
}
//sampleEnd
Rendering requires a Behavior<RangeSlider>
. BasicTheme
provides one.
CircularRangeSlider
CircularRangeSlider
behaves just like a regular RangeSlider, except it is meant to be a ring.
Like RangeSlider
, 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.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.range.CircularRangeSlider
import io.nacular.doodle.geometry.Size
//sampleStart
val circularRangeSlider = CircularRangeSlider(value = 10 .. 30, limits = 1 .. 100).apply {
suggestSize(Size(100, 100))
// ticks = 10
// snapToTicks = true
}
//sampleEnd
Rendering requires a Behavior<CircularRangeSlider>
. BasicTheme
provides one.
SpinButton
SpinButton
is a list data structure analog that lets you represent a list of items where only one is visible (selected) at a time. They work well when the list of options is relatively small, or the input is an incremental value: like the number of items to purchase.
SpinButton takes a SpinButtonModel
that works like an Iterator
. This allows them to represent an open-ended list of items that do not need to be loaded up front.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.spinbutton.SpinButton
//sampleStart
val spinButton1 = SpinButton(1..9 step 2)
val spinButton2 = SpinButton(listOf("Monday", "Tuesday", "Wednesday"))
//sampleEnd
Rendering requires a SpinButtonBehavior
. BasicTheme
provides one.
MutableSpinButton
is an editable version of the SpinButton
that takes a MutableSpinButtonModel
and lets you change it's values directly, or by updating it's model.
You can edit the items in this example by simply clicking on the box and hitting enter when done. Clicking away will cancel the edit.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.ItemVisualizer
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.spinbutton.MutableListSpinButtonModel
import io.nacular.doodle.controls.spinbutton.MutableSpinButton
import io.nacular.doodle.controls.spinbutton.SpinButton
import io.nacular.doodle.controls.spinbutton.spinButtonEditor
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.drawing.Color.Companion.Transparent
import io.nacular.doodle.event.PointerListener.Companion.pressed
import io.nacular.doodle.focus.FocusManager
import io.nacular.doodle.layout.constraints.fill
import io.nacular.doodle.system.Cursor.Companion.Text
import io.nacular.doodle.theme.basic.spinbutton.SpinButtonTextEditOperation
import io.nacular.doodle.utils.ToStringIntEncoder
fun mutableSpinButton(focusManager: FocusManager) {
// custom visualizer to initiate edit on pointer press
val itemVisualizer: ItemVisualizer<Int, SpinButton<Int, *>> = itemVisualizer { item, previous, button ->
when (previous) {
is Label -> previous.also { it.text = "$item" }
else -> Label("$item").apply {
this.font = previous?.font
cursor = Text
foregroundColor = previous?.foregroundColor
backgroundColor = previous?.backgroundColor ?: Transparent
// Begin edit on pointer press
pointerFilter += pressed { event ->
(button as MutableSpinButton).startEditing()
event.consume()
}
}
}
}
//sampleStart
MutableSpinButton(
MutableListSpinButtonModel((1 .. 9).toMutableList()),
itemVisualizer
).apply {
cellAlignment = fill
editor = spinButtonEditor { spinButton, value, current ->
SpinButtonTextEditOperation(
focusManager,
ToStringIntEncoder,
spinButton,
value,
current
)
}
}
//sampleEnd
}
Rendering requires a MutableSelectBoxBehavior
. BasicTheme
provides one.
SelectBox
SelectBox
is a list data structure similar to SpinButton. It also lets you represent a list of choices where only one is visible (selected) at a time.But unlike a SpinButton
, the choices are shown in a list when the control is activated. They work well when the list of options is relatively small.
SelectBox
takes a ListModel
that works like an Iterator
. This allows it to represent an open-ended list of items that do not need to be loaded up front.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.selectbox.SelectBox
//sampleStart
val selectBox1 = SelectBox(1..9 step 2)
val selectBox2 = SelectBox(listOf("Left", "Center", "Right"))
//sampleEnd
Rendering requires a SelectBoxBehavior
. BasicTheme
provides one.
MutableSelectBox
is an editable version of the SelectBox
that takes a MutableListModel
and lets you change it's values directly, or by updating it's model.
You can edit the items in this example by simply clicking on the box and hitting enter when done. Clicking away will cancel the edit.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.IndexedItem
import io.nacular.doodle.controls.ItemVisualizer
import io.nacular.doodle.controls.SimpleMutableListModel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.selectbox.MutableSelectBox
import io.nacular.doodle.controls.selectbox.selectBoxEditor
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.drawing.Color.Companion.Transparent
import io.nacular.doodle.event.PointerListener.Companion.pressed
import io.nacular.doodle.focus.FocusManager
import io.nacular.doodle.layout.constraints.fill
import io.nacular.doodle.system.Cursor.Companion.Text
import io.nacular.doodle.theme.basic.selectbox.SelectBoxTextEditOperation
import io.nacular.doodle.utils.PassThroughEncoder
private class MutableSelectBox(focusManager: FocusManager) {
// custom visualizer to initiate edit on pointer press
val boxItemVisualizer: ItemVisualizer<String, IndexedItem> = itemVisualizer { item, previous, _ ->
when (previous) {
is Label -> previous.also { it.text = item }
else -> Label(item).apply {
this.font = previous?.font
cursor = Text
foregroundColor = previous?.foregroundColor
backgroundColor = previous?.backgroundColor ?: Transparent
// Begin edit on pointer press
pointerFilter += pressed { event ->
mutableSelectBox.startEditing()
event.consume()
}
}
}
}
//sampleStart
val mutableSelectBox = MutableSelectBox(
SimpleMutableListModel(listOf("Left", "Center", "Right")),
boxItemVisualizer
).apply {
boxCellAlignment = fill
editor = selectBoxEditor { selectBox, value, current ->
SelectBoxTextEditOperation(
focusManager,
PassThroughEncoder(),
selectBox,
value,
current
)
}
}
//sampleEnd
}
Rendering requires a MutableSelectBoxBehavior
. BasicTheme
provides one.
Menu
Menu
is a View
that contains a vertical list of interactive items. These items notify whenever the user interacts with them using a pointer or the keyboard. This control support a couple types of action items and one that shows a new "sub" menu when the user interacts with it. You specify the contents of a Menu
using the MenuFactory
, which provides a declarative DSL for defining the structure and behavior of Menus.
Note that the Menu
implementation does not handle showing it as a popup. But this is easy to create using the PopupManager
or ModalManager
. The following app does just this. It has a button that shows a menu as a modal when clicked.
You must include the MenuFactoryModule
(Web, Desktop) in your application in order to use these features.
package controls
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.Modules.Companion.MenuFactoryModule
import io.nacular.doodle.application.application
import io.nacular.doodle.controls.popupmenu.MenuFactory
import io.nacular.doodle.core.Display
import org.kodein.di.instance
class MenuApp(display: Display, menus: MenuFactory): Application {
override fun shutdown() {}
}
fun menusModule() {
//sampleStart
application(modules = listOf(MenuFactoryModule)) {
MenuApp(display = instance(), menus = instance())
}
//sampleEnd
}
Doodle uses opt-in modules like this to improve bundle size.
- Demo
- Usage
Move the button around to see how the Menu
adjusts the location of its popups to keep them visible as much as possible.
package controls
import io.nacular.doodle.controls.popupmenu.MenuBehavior.ItemInfo
import io.nacular.doodle.controls.popupmenu.MenuFactory
import io.nacular.doodle.core.Icon
/**
* Be sure to install a `MenuBehavior` (i.e. `basicMenuBehavior`, or your own) to so the Menu renders.
*/
fun example(menus: MenuFactory, someIcon: Icon<ItemInfo>) {
//sampleStart
val menu = menus(close = { /* close menu */ }) {
menu("Sub menu") {
prompt ("Prompt" ) { /* handle */ }
separator( )
action ("Do something", icon = someIcon) { /* handle */ }.apply { enabled = false }
menu ("Sub-sub menu" ) {
action ("Some action" ) { /* handle */ }
separator( )
action ("Another action") { /* handle */ }
}
}
action("Execute action") { /* handle */ }
}
//sampleEnd
}
You also need to provide a MenuBehavior
or use a Theme
with one since Menu
delegates rendering. The example above uses basicMenuBehavior
which is also available as a module within BasicTheme
.
- Demo
- Code
package controls
import io.nacular.doodle.controls.buttons.PushButton
import io.nacular.doodle.controls.modal.ModalManager
import io.nacular.doodle.controls.modal.ModalManager.RelativeModal
import io.nacular.doodle.controls.popupmenu.MenuFactory
import io.nacular.doodle.controls.popupmenu.MenuItem
import io.nacular.doodle.event.PointerListener.Companion.clicked
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.layout.constraints.Strength.Companion.Strong
import io.nacular.doodle.utils.Resizer
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Be sure to install a `MenuBehavior` (i.e. `basicMenuBehavior`, or your own) to so the Menu renders.
*/
fun popupMenu(
appScope : CoroutineScope,
uiDispatcher: CoroutineDispatcher,
modal : ModalManager,
menu : MenuFactory,
toast : (MenuItem) -> Unit
) {
//sampleStart
val button = PushButton("Menu").apply {
suggestBounds(Rectangle(20, 20, 100, 30))
// Show menu as modal
fired += {
appScope.launch(uiDispatcher) {
modal {
val popup = menu(close = { completed(Unit) }) {
menu ("New" ) {
prompt ("Project" ) { toast(it) }
prompt ("Project from Existing Sources") { toast(it) }
prompt ("Project from Version Control" ) { toast(it) }
prompt ("Module" ) { toast(it) }
prompt ("Module from Existing Sources" ) { toast(it) }
separator( )
action ("Kotlin Class/File" ) { toast(it) }
action ("File" ) { toast(it) }
action ("Scratch File" ) { toast(it) }
action ("Directory" ) { toast(it) }
separator( )
action ("Driver and Data Source" ) { toast(it) }
action ("Driver" ) { toast(it) }
menu ("ignore File" ) {
action(".gitignore File (Git)" ) { toast(it) }
action(".bzrignore File (Bazaar)" ) { toast(it) }
action(".cfignore File (CloudFoundry)" ) { toast(it) }
action(".chefignore File (Chef)" ) { toast(it) }
action(".cvsignore File (CVs)" ) { toast(it) }
action(".boringignore File (Darcs)" ) { toast(it) }
action(".dockerignore File (Docker)" ) { toast(it) }
action(".ebignore File (ElasticBeanstalk)") { toast(it) }
}
}
prompt ("Open" ) { toast(it) }
prompt ("Open Source Code from URL") { toast(it) }
action ("Open Recent" ) { toast(it) }
separator( )
menu ("File Properties" ) {
action("File Encoding" ) { toast(it) }
action("Remove BOM" ) { toast(it) }.apply { enabled = false }
action("Add BOM" ) { toast(it) }
prompt("Associate with File Type") { toast(it) }
action("Make File Read-Only" ) { toast(it) }
menu ("Line Separators" ) {
action("CRLF - Windows (rin)" ) { toast(it) }
action("LF - Unix and macOS (In)") { toast(it) }.apply { enabled = false }
action("CR - Classic Mac OS (r)" ) { toast(it) }
}
}
separator( )
action ("Save" ) { toast(it) }
prompt ("Save As" ) { toast(it) }
action ("Reload File from Disk" ) { toast(it) }
separator( )
prompt ("Print" ) { toast(it) }
}
allowPointerThrough = true
pointerOutsideModalChanged += clicked { completed(Unit) }
// Show popup menu relative to button and keep it visible in the Display
// as much as possible
RelativeModal(popup, this@apply) { modal, button ->
modal.top eq button.bottom + 10 strength Strong
modal.top greaterEq 5 strength Strong
modal.left greaterEq 5 strength Strong
modal.bottom lessEq parent.bottom - 5 strength Strong
modal.centerX eq button.center.x strength Strong
modal.right lessEq parent.right - 5
when {
parent.height.readOnly - button.bottom > modal.height.readOnly + 15 -> modal.bottom lessEq parent.bottom - 5
else -> modal.bottom lessEq button.y - 10
}
modal.size.preserve
}
}
}
}
Resizer(this)
}
//sampleEnd
}
Menus work really well when shown as modals via the ModalManager
Carousel
The Carousel
control is a visual analog to the list data structure. It is a readonly (see DynamicCarousel
if you want one that responds to changes in a DynamicListModel
), ordered, generic collection of items with random
access to its members. It provides memory optimization by only rendering the contents displayed by its Presenter
, and recycling views as it scrolls. The result is that Carousels can hold extremely large data sets without impacting performance.
You need 3 things to create a Carousel: a ListModel
, ItemVisualizer
and CarouselBehavior
. The model represents the data within the Carousel, and the visualizer provides a way to translate each item in the model to a View
that will be rendered within the Carousel. The behavior provides a Presenter
and Transitioner
, both of which are required to render the contents of a Carousel.
Carousels are very flexible containers that can be fully customized via their Presenter
and Transitioner
.
Presenter
s decide what items are shown in the Carousel and specify theirbounds
,transform
,opacity
,zOrder
etc..Transitioner
s control how a Carousel moves between items.
- Demo
- Usage
package controls
import io.nacular.doodle.accessibility.ImageRole
import io.nacular.doodle.controls.ListModel
import io.nacular.doodle.controls.SimpleListModel
import io.nacular.doodle.controls.carousel.Carousel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.core.View
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.event.KeyCode.Companion.ArrowLeft
import io.nacular.doodle.event.KeyCode.Companion.ArrowRight
import io.nacular.doodle.event.KeyListener
import io.nacular.doodle.event.PointerListener
import io.nacular.doodle.event.PointerMotionListener
import io.nacular.doodle.focus.FocusManager
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.Image
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.utils.Resizer
import io.nacular.doodle.utils.observable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
private class Photo(image: Image): View(accessibilityRole = ImageRole()) {
var image: Image by observable(image) { _,_ ->
update()
}
init {
enabled = false
update()
}
private fun update() {
suggestSize(image.size)
}
override fun render(canvas: Canvas) {
canvas.image(image, destination = bounds.atOrigin)
}
override fun toString() = image.source
}
fun carouselExample(scope: CoroutineScope, images: ImageLoader, focusManager: FocusManager) {
//sampleStart
scope.launch {
val model = SimpleListModel(listOf(1, 2, 3, 4,).map {
images.load("/images/carousel$it.jpg")!!
})
val carousel = Carousel<Image, ListModel<Image>>(model, itemVisualizer { item,previous,_ ->
when (previous) {
is Photo -> previous.apply { image = item }
else -> Photo(item).apply { image = item }
}
}).apply {
suggestSize(model[0].getOrThrow().size.run { Size(width * 0.85, height * 0.85) })
keyChanged += KeyListener.pressed {
when (it.code) {
ArrowLeft -> skip(-1)
ArrowRight -> skip(1 )
}
}
var touchLocation = Origin
pointerChanged += PointerListener.on(
pressed = {
touchLocation = toLocal(it.location, it.target)
startManualMove()
it.consume()
},
released = { completeManualMove() },
clicked = { focusManager.requestFocus(this) }
)
pointerMotionChanged += PointerMotionListener.dragged {
if (it.source == this) {
moveManually(toLocal(it.location, it.target) - touchLocation)
it.consume()
}
}
Resizer(this, movable = false)
}
}
//sampleEnd
}
Adjust the presenter used for the Carousel above to see examples of some very different behaviors that can be achieved. This demo uses the following built-in Presenters:
Presenter
s can also add Supplemental Views to the Carousel to support the items generated from the model. The ReflectionPresenter
does this to represent the "floor" where the items are reflected.
Carousel-based Calendar
The following shows a simple calendar built with a Carousel. The data model is a list of LocalDate
values at the start of each month. In this case a total of 10 years into the future is represented. The LinearPresenter
is used with constraints that show 2 months side-by-side when the calendar is wide enough and only 1 month when it is too small.
- Demo
- Usage
val carousel = Carousel(
MonthModel(startDate .. endDate),
itemVisualizer { item, recycled, _ ->
// buttons and days-of-week are Views overlaid on the Carousel for simplicity
when (recycled) {
is NamedMonthPanel -> recycled.also { it.monthPanel.setDate(item) }
else -> NamedMonthPanel(
item,
itemVisualizer { day, recycled, month ->
when (recycled) {
is CalendarDay -> recycled.apply { update(month, day) }
else -> CalendarDay(today, day, month, selectionModel)
}
},
selectionModel = selectionModel, // Months all share the same SelectionModel
)
}
}
).apply {
behavior = object: CarouselBehavior<LocalDate> {
// LinearPresenter controls how months are shown
override val presenter = LinearPresenter<LocalDate>(spacing = { MEDIUM_GAP }) {
it.left eq 0
it.height eq parent.height
when {
parent.width.readOnly > singleMonthSize -> it.width eq kotlin.math.max(0.0, (parent.width - MEDIUM_GAP) / 2)
else -> it.width eq parent.width
}
}
// tween animation between frames
override val transitioner = object: Transitioner<LocalDate> {
override fun transition(
carousel : Carousel<LocalDate, *>,
startItem: Int,
endItem : Int,
update : (progress: Float) -> Unit
): Pausable = animate.invoke(0f to 1f, using = tweenFloat(easeInOutCubic, duration = 250 * milliseconds)) {
update(it)
}
}
}
}
StarRater
A highly customizable control that displays a rating between [0, n] using stars. It also lets the user change the underlying value.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.StarRater
import io.nacular.doodle.geometry.Rectangle
//sampleStart
val stars = StarRater(displayRounded = 0f, max = 5, /*pathMetrics = pathMetrics*/).apply {
suggestBounds(Rectangle(200, 50))
minSpacing = 15.0
innerRadiusRatio = 0.6f
starPointRounding = 0.25f
}
//sampleEnd
List
The List
control is a visual analog to the list data structure. It is a readonly, ordered, generic collection of items with random
access to its members.
You need 2 things to create a List: a ListModel
, and ItemVisualizer
.
You also need to provide a ListBehavior
or use a Theme
with one since List
delegates rendering. The examples below use BasicListBehavior
which is also available as a module within BasicTheme
.
The model represents the data within the List, and the visualizer provides a way to translate each item to a View
that will be rendered within
the List.
Lists provide memory optimization by only rendering the contents within their viewport, recycling items to display new rows. The default setting caches 10 extra items; but this can be changed with the scrollCache
property when creating the List.
The following shows a DynamicList
of countries (a custom data class). These Lists are useful when the underlying model can change after creation. This demo loads images asynchronously and adds new countries to the model as they load. The demo also illustrates a custom visualizer that represents each country as a name label and flag image.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.SimpleMutableListModel
import io.nacular.doodle.controls.StringVisualizer
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.list.DynamicList
import io.nacular.doodle.docs.utils.Country
import io.nacular.doodle.docs.utils.CountryView
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.layout.constraints.fill
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
fun example(scope: CoroutineScope, imageLoader: ImageLoader) {
//sampleStart
val model = SimpleMutableListModel<Country>()
scope.launch {
listOf(
"United Kingdom" to "images/197374.svg",
"United States" to "images/197484.svg",
"France" to "images/197560.svg",
"Germany" to "images/197571.svg",
"Spain" to "images/197593.svg",
// ...
).sortedBy { it.first }.map { (name, path) ->
imageLoader.load(path)?.let { image ->
model.add(Country(name, image))
}
}
}
val stringVisualizer = StringVisualizer()
val list = DynamicList(
model,
selectionModel = MultiSelectionModel(),
itemVisualizer = itemVisualizer { item, previous, context ->
when (previous) {
is CountryView -> previous.apply {
update(
index = context.index,
country = item,
selected = context.selected
)
}
else -> CountryView(
stringVisualizer,
index = context.index,
country = item,
selected = context.selected
)
}
}
).apply {
cellAlignment = fill
}
//sampleEnd
}
- Columns
- Usage
package controls
import io.nacular.doodle.controls.DynamicListModel
import io.nacular.doodle.controls.IndexedItem
import io.nacular.doodle.controls.ItemVisualizer
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.list.VerticalDynamicList
import io.nacular.doodle.docs.utils.Country
import io.nacular.doodle.docs.utils.CountryView
import io.nacular.doodle.layout.constraints.fill
fun verticalList(model: DynamicListModel<Country>, stringVisualizer: ItemVisualizer<String, IndexedItem>) {
//sampleStart
val list = VerticalDynamicList(
model,
selectionModel = MultiSelectionModel(),
itemVisualizer = itemVisualizer { item, previous, context ->
when (previous) {
is CountryView -> previous.apply {
update(
index = context.index,
country = item,
selected = context.selected
)
}
else -> CountryView(
stringVisualizer,
index = context.index,
country = item,
selected = context.selected
)
}
}
).apply {
cellAlignment = fill
}
//sampleEnd
}
- Rows
- Usage
package controls
import io.nacular.doodle.controls.DynamicListModel
import io.nacular.doodle.controls.IndexedItem
import io.nacular.doodle.controls.ItemVisualizer
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.list.HorizontalDynamicList
import io.nacular.doodle.docs.utils.Country
import io.nacular.doodle.docs.utils.CountryView
import io.nacular.doodle.layout.constraints.fill
fun horizontalList(model: DynamicListModel<Country>, stringVisualizer: ItemVisualizer<String, IndexedItem>) {
//sampleStart
val list = HorizontalDynamicList(
model,
selectionModel = MultiSelectionModel(),
itemVisualizer = itemVisualizer { item, previous, context ->
when (previous) {
is CountryView -> previous.apply {
update(
index = context.index,
country = item,
selected = context.selected
)
}
else -> CountryView(
stringVisualizer,
index = context.index,
country = item,
selected = context.selected
)
}
}
).apply {
cellAlignment = fill
}
//sampleEnd
}
This List displays a set of countries, with each having a name and flag image. A DynamicList
is used here because
the underlying model
changes as each country is added asynchronously when its image loads.
DynamicList
is readonly (though its models may change), while MutableList
is read/write.
Table
A Table
is very similar to a List
(readonly analog to the list data structure). It is like a List
that can display structured
data for each entry they hold. It is also strongly typed and homogeneous, like List. So each item is of some type <T>
. The values of each column are therefore derivable from each <T>
in the table. The Table below contains a list of Person
and has columns for the name
, age
, and attending
(whether they are attending an event). Columns can also produce arbitrary values, which is done to show the index of each item.
Each column's CellVisualizer
ultimately controls what is displayed in it. The visualizer is given the value of each element in that column to produce a View. So the Name column gets a String
, while the Attending column gets a Boolean
. The first column has values of type Unit
, and uses the RowNumberGenerator
to display the index of each item.
DynamicTable
supports changes to its model, and MutableTable
allows editing.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.BooleanVisualizer
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.table.CellInfo
import io.nacular.doodle.controls.table.Table
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.controls.toString
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
import io.nacular.doodle.layout.constraints.center
fun tableExample() {
//sampleStart
data class Person(val name: String, val age: Int, val attending: Boolean)
val textVisualizer = highlightingTextVisualizer()
// Generates a string for each row's index
val indexVisualizer = itemVisualizer<Unit, CellInfo<Person, Unit>> { _, previous, context ->
textVisualizer("${context.index + 1}", previous, context)
}
val data = listOf(
Person(name = "Alice", age = 53, attending = false),
Person(name = "Bob", age = 35, attending = true ),
Person(name = "Jack", age = 8, attending = true ),
Person(name = "Jill", age = 5, attending = false)
)
val table = Table(data, MultiSelectionModel()) {
column(Label("#" ), indexVisualizer ) { minWidth = 50.0; width = 50.0; maxWidth = 150.0; cellAlignment = center }
column(Label("Name" ), { name }, textVisualizer ) { minWidth = 100.0; cellAlignment = { it.left eq 10 } }
column(Label("Age" ), { age }, toString(textVisualizer)) { minWidth = 100.0; width = 100.0; maxWidth = 150.0; cellAlignment = { it.left eq 10 }; headerAlignment = cellAlignment }
column(Label("Attending"), { attending }, BooleanVisualizer() ) { minWidth = 100.0; width = 100.0; maxWidth = 150.0; cellAlignment = center }
}
//sampleEnd
}
Tables require a TableBehavior
for rendering. BasicTheme
provides one.
MutableTable
This is a table that can modify its underlying model
. That means the table can do CRUD operations or sorting that will modify the model. This example shows sorting.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.BooleanVisualizer
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.mutableListModelOf
import io.nacular.doodle.controls.table.CellInfo
import io.nacular.doodle.controls.table.MutableTable
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.docs.utils.AccessibleVisualizer
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
import io.nacular.doodle.layout.constraints.center
fun mutableTableExample() {
//sampleStart
data class Person(val name: String, val age: Int, val attending: Boolean)
val textVisualizer = highlightingTextVisualizer()
// Generates a string for each row's index
val indexVisualizer = itemVisualizer<Unit, CellInfo<Person, Unit>> { _, previous, context ->
textVisualizer("${context.index + 1}", previous, context)
}
val data = mutableListModelOf(
Person(name = "Alice", age = 53, attending = false),
Person(name = "Bob", age = 35, attending = true ),
Person(name = "Jack", age = 8, attending = true ),
Person(name = "Jill", age = 5, attending = false)
)
val table = MutableTable(data, MultiSelectionModel()) {
column(Label("#" ), indexVisualizer ) { minWidth = 50.0; width = 50.0; maxWidth = 150.0; cellAlignment = center }
column(Label("Name" ), { name }, textVisualizer, sortable = true) { minWidth = 100.0; cellAlignment = { it.left eq 10 } }
column(Label("Age" ), { age }, io.nacular.doodle.controls.toString(textVisualizer), sortable = true) { minWidth = 100.0; width = 100.0; maxWidth = 150.0; cellAlignment = center }
column(Label("Attending"), { attending }, AccessibleVisualizer(BooleanVisualizer(), "Attending"), sortable = true) { minWidth = 100.0; width = 100.0; maxWidth = 150.0; cellAlignment = center }
}
//sampleEnd
}
Tables require a TableBehavior
for rendering. BasicTheme
provides one.
KeyValueTable
This is a table that represents a key-value map of data. The table only has two columns: one for the key and value in each pair.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.after
import io.nacular.doodle.controls.table.ColumnInfo
import io.nacular.doodle.controls.table.KeyValueTable
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
import io.nacular.doodle.layout.constraints.Bounds
import io.nacular.doodle.layout.constraints.ConstraintDslContext
fun keyValueTable(numberFormatter: (Int) -> String) {
//sampleStart
val textVisualizer = highlightingTextVisualizer()
val numberVisualizer = textVisualizer.after { it: Int -> numberFormatter(it) }
val data = mapOf(
"New York City, NY" to 8_622_357,
"Los Angeles, CA" to 4_085_014,
"Chicago, IL" to 2_670_406,
"Houston, TX" to 2_378_146,
"Phoenix, AZ" to 1_743_469,
"Philadelphia, PA" to 1_590_402,
"San Antonio, TX" to 1_579_504,
"San Diego, CA" to 1_469_490,
"Dallas, TX" to 1_400_337,
"San Jose, CA" to 1_036_242,
)
val alignment: ConstraintDslContext.(Bounds) -> Unit = {
it.left eq 10
it.centerY eq parent.centerY
}
val table = KeyValueTable(
values = data,
keyColumn = ColumnInfo(Label("City" ), textVisualizer ) { headerAlignment = alignment; cellAlignment = alignment },
valueColumn = ColumnInfo(Label("Population"), numberVisualizer) { headerAlignment = alignment; cellAlignment = alignment },
selectionModel = MultiSelectionModel(),
)
//sampleEnd
}
KeyValueTables require a TableBehavior
for rendering. BasicTheme
provides one.
Tree
The Tree
control is a visual analog to the tree data structure. It is a readonly, hierarchical, generic collection of items that are accessible via a numeric path.
You need 2 things to create a Tree: a TreeModel
, and ItemVisualizer
.
You also need to provide a Behavior or use a Theme with one since Tree delegates rendering.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.tree.SimpleTreeModel
import io.nacular.doodle.controls.tree.Tree
import io.nacular.doodle.controls.tree.rootNode
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
//sampleStart
val root = rootNode("") {
node("Applications")
node("Desktop" )
node("Documents" ) {
node("Image.jpg")
node("Todos.txt")
}
node("Downloads" )
node("Movies" )
node("Music" ) {
node("Track1.mp3")
node("Track2.mp3")
node("Track3.mp3")
node("Track4.mp3")
}
node("Photos" ) {
node("Capture1.jpg")
node("Capture2.jpg")
node("Capture3.jpg")
node("Capture4.jpg")
}
}
val tree = Tree(
model = SimpleTreeModel(root),
itemVisualizer = highlightingTextVisualizer(),
selectionModel = MultiSelectionModel()
)
//sampleEnd
This creates a Tree from the nodes defined. This demo also places the Tree in a resizable ScrollPanel; but that code is excluded for simplicity. Trees--like Lists--provide memory optimized rendering.
Trees require a TreeBehavior
for rendering. BasicTheme
provides one.
DynamicTree
is readonly (though its models may change), while MutableTree
is read/write.
TreeTable
The TreeTable
is very similar to a Tree
, except it shows a structured set of fields from each item as columns, like a Table
.
You need 2 things to create a Tree: a TreeModel
, and ItemVisualizer
.
You also need to provide a Behavior or use a Theme with one since Tree delegates rendering.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.table.TreeTable
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.controls.tree.SimpleTreeModel
import io.nacular.doodle.controls.tree.TreeNode
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
import io.nacular.measured.units.BinarySize
import io.nacular.measured.units.BinarySize.Companion.bytes
import io.nacular.measured.units.BinarySize.Companion.gigabytes
import io.nacular.measured.units.BinarySize.Companion.kilobytes
import io.nacular.measured.units.BinarySize.Companion.megabytes
import io.nacular.measured.units.Measure
import io.nacular.measured.units.times
import io.nacular.measured.units.toNearest
interface File {
val name: String get() = ""
val size: Measure<BinarySize> get() = 0 * bytes
fun toNode(): TreeNode<File> {
return TreeNode(this)
}
companion object {
operator fun invoke(name: String, size: Measure<BinarySize> = 0 * bytes) = object: File {}
}
}
interface Directory: File {
interface DirectoryBuilder
val children: List<File> get() = TODO("Not yet implemented")
override fun toNode(): TreeNode<File> {
return TreeNode(this, children.map { it.toNode() })
}
companion object {
operator fun invoke(name: String, children: DirectoryBuilder.() -> Unit) = object: Directory {}
}
}
private fun Measure<BinarySize>.toSmallestUnit(): Measure<BinarySize> {
listOf(bytes, kilobytes, megabytes, gigabytes).forEach {
if (this >= 1 * it) return (this `as` it).toNearest(0.1 * it)
}
return this
}
fun treeTable() {
//sampleStart
val root = Directory("") {
File("Applications", 100 * gigabytes)
File("Desktop", 79 * bytes )
Directory("Documents") {
File("Image.jpg", 256 * kilobytes)
File("Todos.txt", 10 * megabytes)
}
Directory("Downloads") {}
File("Movies", 1.8 * gigabytes)
Directory("Music") {
File("Track1.mp3", 3.6 * megabytes)
File("Track2.mp3", 867 * kilobytes)
File("Track3.mp3", 3 * megabytes)
File("Track4.mp3", 12 * megabytes)
}
Directory("Photos") {
File("Capture1.jpg", 105 * megabytes)
File("Capture2.jpg", 87 * megabytes)
File("Capture3.jpg", 1000 * kilobytes)
File("Capture4.jpg", 1.1 * gigabytes)
}
}
val treeTable = TreeTable(
model = SimpleTreeModel(root.toNode()),
selectionModel = MultiSelectionModel()
) {
val textVisualizer = highlightingTextVisualizer()
column(Label("Name"), { name }, textVisualizer) { width = 150.0 }
column(Label("Size"), { "${size.toSmallestUnit()}" }, textVisualizer) {
width = 150.0
cellAlignment = {
it.right eq parent.right - 10
it.width.preserve
it.centerY eq parent.centerY
}
}
}
//sampleEnd
}
This creates a Tree from the nodes defined. This demo also places the Tree in a resizable ScrollPanel; but that code is excluded for simplicity. Trees--like Lists--provide memory optimized rendering.
Trees require a TreeBehavior
for rendering. BasicTheme
provides one.
GridPanel
This control manages a generic list of View
s and displays them within a grid layout. Items can be added to or removed from the panel. Each item added indicates the row/column it sits at and the number of rows / columns it spans. This, along with the rowSizingPolicy
and columnSizingPolicy
control how the items are ultimately laid out.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.panels.GridPanel
import io.nacular.doodle.controls.panels.GridPanel.Companion.FitPanel
import io.nacular.doodle.core.View
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.utils.Resizer
fun gridPanel(
view1: View,
view2: View,
view3: View,
view4: View,
view5: View,
view6: View,
view7: View
) {
//sampleStart
val panel = GridPanel().apply {
rowSpacing = { 10.0 }
columnSpacing = { 10.0 }
rowSizingPolicy = FitPanel // FitContent, or custom policy
columnSizingPolicy = FitPanel // FitContent, or custom policy
add(view1, columnSpan = 2 ) // defaults to row = 0, col = 0
add(view2, row = 1, column = 0)
add(view3, row = 1, column = 1)
add(view4, row = 2, column = 2)
add(view5, row = 0, column = 2, rowSpan = 2)
add(view6, row = 2, column = 0)
add(view7, row = 2, column = 1)
suggestSize(Size(200))
Resizer(this, movable = false)
}
//sampleEnd
}
SplitPanel
This control divides a region into two areas, each occupied by a View. It also allows the user to change the portion of its viewport dedicated to either view.
- Demo
- Horizontal
- Usage
package controls
import io.nacular.doodle.controls.Photo
import io.nacular.doodle.controls.panels.ScrollPanel
import io.nacular.doodle.controls.panels.SplitPanel
import io.nacular.doodle.docs.utils.CircularView
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.Image
import io.nacular.doodle.layout.Insets
import io.nacular.doodle.utils.Orientation.Vertical
fun splitPanel(image: Image) {
//sampleStart
val panel = SplitPanel(orientation = Vertical /*| Horizontal*/).apply {
suggestSize(Size(500, 300))
firstItem = ScrollPanel(CircularView(250.0))
lastItem = Photo(image)
ratio = 1f / 3
insets = Insets(2.0)
}
//sampleEnd
}
This shows how you might nest horizontal and vertical SplitPanels.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.SingleItemSelectionModel
import io.nacular.doodle.controls.StringVisualizer
import io.nacular.doodle.controls.list.List
import io.nacular.doodle.controls.panels.ScrollPanel
import io.nacular.doodle.controls.panels.SplitPanel
import io.nacular.doodle.controls.toString
import io.nacular.doodle.docs.utils.panel
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.utils.Orientation.Horizontal
import io.nacular.doodle.utils.Resizer
fun nestedSplitPanel(textMetrics: TextMetrics) {
//sampleStart
val list = List(1..10, toString(StringVisualizer()), SingleItemSelectionModel()).apply {
cellAlignment = { it.left eq 10 }
}
val main = panel(textMetrics, "1", shadow = null, cornerRadius = 0.0)
val info = panel(textMetrics, "info", shadow = null, cornerRadius = 0.0)
val panel = SplitPanel(orientation = Horizontal).apply {
suggestSize(Size(500, 300))
firstItem = SplitPanel().apply {
firstItem = ScrollPanel(list).apply {
contentWidthConstraints = { it eq parent.width }
contentHeightConstraints = { it eq it.idealValue }
}
lastItem = main
ratio = 1f / 3
}
ratio = 2f / 3
lastItem = info
Resizer(this, movable = false)
}
/**
* Launched App using
* -------------------------------------------
* basicListBehavior(),
* basicLabelBehavior(),
* basicSplitPanelBehavior(showDivider = true),
* nativeScrollPanelBehavior()
*/
//sampleEnd
}
Requires a SplitPanelBehavior
for rendering. BasicTheme
provides one.
TabbedPanel
This control manages a generic list of items and displays them one at a time using an ItemVisualizer
. Each item is generally
tracked with a visual "tab" that allows selection of particular items in the list.
The panel takes 2 visualizers; one to convert each item to a View that will be displayed as the tab, and another to convert each item to the main tab content. The View returned from the main visualizer will be scaled to fit the TabPanel when using basicTabbedPanelBehavior
. In this example, we use a ScrollPanelVisualizer
for the main content so it turns into a ScrollPanel with our view embedded. That works well for TabbedPanels that are meant to display arbitrary Views.
- Demo
- Usage
package tabbedpanel
import io.nacular.doodle.controls.ScrollPanelVisualizer
import io.nacular.doodle.controls.StringVisualizer
import io.nacular.doodle.controls.invoke
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.panels.TabbedPanel
import io.nacular.doodle.core.view
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.utils.Resizer
fun example() {
//sampleStart
val object1 = view {}
val object2 = view {}
val object3 = view {}
val object4 = view {}
val textVisualizer = StringVisualizer()
val mapping = mapOf(
object1 to "Circle",
object2 to "Second Tab",
object3 to "Cool Photo",
object4 to "Tab 4"
)
val panel = TabbedPanel(
visualizer = ScrollPanelVisualizer(), // Each object is displayed within a ScrollPanel
tabVisualizer = itemVisualizer { item,_,_ -> textVisualizer(mapping[item] ?: "Unknown") }, // Each tab shows a hardcoded string from mapping
object1,
object2,
object3,
object4
).apply {
suggestSize(Size(500, 300))
Resizer(this, movable = false)
}
//sampleEnd
}
This control requires a TabbedPanelBehavior
for rendering. This demo uses the basicTabbedPanelBehavior
module which installs BasicTabbedPanelBehavior
ColorPicker
This control allows a user to pick an RGB color by specifying a hue and opacity.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.ColorPicker
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.geometry.Size
fun colorPicker() {
//sampleStart
val picker = ColorPicker(Black).apply {
suggestSize(Size(300))
// ...
}
println("Selected color is ${picker.color}")
picker.changed += { _, old, new ->
println("Color changed from: $old to $new")
}
//sampleEnd
}
MonthPanel
This control displays the days of a given month. It does not display a header with the day of the week though. This functionality is provided separately in the DaysOfTheWeekPanel. This simplifies reuse as a core component of calendars. Excluding the header means the MonthPanel
can be used in vertically scrolling calendars where the days are pinned to the top. Or in horizontal setups where the days are attached to it (within a container that has both panels).
The panel can either show or hide days in the adjacent months using showAdjacentMonths
. It can also start at any day of the week via the weekStart
property.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.date.MonthPanel
import io.nacular.doodle.geometry.Size
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
//sampleStart
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val monthPanel = MonthPanel(today, /*itemVisualizer, selectionModel*/).apply {
suggestSize(Size(300))
// ...
}
//sampleEnd
DaysOfTheWeekPanel
This control is meant as a header for the MonthPanel
. It shows days of the week starting at the given weekStart
property.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.date.DaysOfTheWeekPanel
import kotlinx.datetime.DayOfWeek
//sampleStart
val panel = DaysOfTheWeekPanel(weekStart = DayOfWeek.MONDAY).apply {
// ...
}
/*
styled using
basicDaysOfTheWeekPanelBehavior(defaultVisualizer = itemVisualizer { day, previous, _ ->
val text = StyledText(day.name.take(1))
when (previous) {
is Label -> previous.apply { styledText = text }
else -> Label(text)
}
})
*/
//sampleEnd
Custom Calendar
This shows the MonthPanel
and DaysOfTheWeekPanel
being used to create a simple vertical calendar with one column. This calendar uses a List<LocalDate>
with a model that contains the months of the current year. Each date in the list is visualized using a custom View that simply holds a label and
MonthPanel
. These are updated as the list scrolls and items are recycled.
The custom View provides a visualizer to the MonthPanel
that controls the colors for each day as well as the background selection rendering.
- Demo
- Code
package controls.calendar
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.ItemVisualizer
import io.nacular.doodle.controls.ListModel
import io.nacular.doodle.controls.SelectionModel
import io.nacular.doodle.controls.SingleItemSelectionModel
import io.nacular.doodle.controls.date.DaysOfTheWeekPanel
import io.nacular.doodle.controls.date.MonthPanel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.panels.ScrollPanel
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.View
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Darkgray
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.rect
import io.nacular.doodle.event.PointerEvent
import io.nacular.doodle.event.PointerListener
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.layout.constraints.Strength.Companion.Strong
import io.nacular.doodle.layout.constraints.center
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.fill
import io.nacular.doodle.system.SystemInputEvent
import io.nacular.doodle.text.invoke
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.theme.basic.list.basicVerticalListBehavior
import io.nacular.doodle.utils.Resizer
import kotlinx.datetime.Clock
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.monthsUntil
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.math.min
private class CalendarDay(private val today: LocalDate, private var day: LocalDate, private val panel: MonthPanel): View() {
private val label = Label()
init {
styleChanged += { rerender() }
clipCanvasToBounds = false
children += label
layout = constrain(label) {
it.center eq parent.center
}
pointerChanged += object: PointerListener {
private var pressed = false
private var pointerOver = false
override fun entered (event: PointerEvent) { pointerOver = true }
override fun exited (event: PointerEvent) { pointerOver = false }
override fun pressed (event: PointerEvent) { pressed = true }
override fun released(event: PointerEvent) {
if (pointerOver && pressed) {
setOf(day).also {
panel.apply {
when {
SystemInputEvent.Modifier.Ctrl in event.modifiers || SystemInputEvent.Modifier.Meta in event.modifiers -> toggleSelection(it)
SystemInputEvent.Modifier.Shift in event.modifiers && lastSelection != null -> {
selectionAnchor?.let { anchor ->
val current = day
when {
current < anchor -> setSelection((anchor downTo current))
anchor < current -> setSelection((anchor .. current).toSet())
}
}
}
else -> setSelection(it)
}
}
}
}
pressed = false
}
}
update(panel, day)
}
override fun render(canvas: Canvas) {
backgroundColor?.let {
val oneDay = DatePeriod(days = 1)
var left = width / 2
var right = left
if (panel.selected(day - oneDay)) {
left = 0.0
}
if (panel.selected(day + oneDay)) {
right = width
}
val radius = min(width, height) / 2
canvas.circle(Circle(radius = radius, center = Point(width/2, height/2)), fill = it.paint)
canvas.rect(Rectangle(left, height / 2 - radius, right - left + 1, 2 * radius), fill = it.paint)
}
}
private infix fun LocalDate.downTo(other: LocalDate): Set<LocalDate> {
var d = this
val result = mutableSetOf<LocalDate>()
while (d >= other) {
result += d
d -= DatePeriod(days = 1)
}
return result
}
private fun ClosedRange<LocalDate>.toSet(): Set<LocalDate> {
var d = start
val result = mutableSetOf<LocalDate>()
while (d <= endInclusive) {
result += d
d += DatePeriod(days = 1)
}
return result
}
fun update(panel: MonthPanel, day: LocalDate) {
this.day = day
val text = "${day.dayOfMonth}"
val styledText = when {
day == today && panel.startDate.month == day.month -> Red
panel.selected(day) -> Black
day.month != panel.startDate.month -> Darkgray
else -> White
} { text }
backgroundColor = when {
panel.selected(day) -> Color.Lightgray
else -> null
}
label.styledText = styledText
}
}
private class NamedMonthPanel(
date: LocalDate,
itemVisualizer: ItemVisualizer<LocalDate, MonthPanel> = itemVisualizer { day, previous, panel ->
val text = "${day.dayOfMonth}"
when (previous) {
is Label -> previous.apply { this.text = text }
else -> Label(text)
}.also {
it.enabled = day.month == panel.startDate.month
}
},
selectionModel: SelectionModel<LocalDate>? = null
): View() {
private val header = Label()
val monthPanel = MonthPanel(date, itemVisualizer, selectionModel).apply {
monthChanged += {
updateHeader()
}
acceptsThemes = false
}
private fun updateHeader() {
header.styledText = White { "${monthPanel.startDate.month.name.lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }} ${monthPanel.startDate.year}" }
}
init {
updateHeader()
children += listOf(header, monthPanel)
layout = constrain(header, monthPanel) { h, m ->
h.top eq 0
h.left eq 10
h.height.preserve
m.top eq h.bottom
m.width eq parent.width
m.bottom eq parent.bottom strength Strong
}
}
}
private class MonthModel(private val dates: ClosedRange<LocalDate>): ListModel<LocalDate> {
private val LocalDate.firstDayOfMonth: LocalDate get() = LocalDate(year = year, month = month, dayOfMonth = 1)
override val size = dates.start.firstDayOfMonth.monthsUntil(dates.endInclusive.firstDayOfMonth) + 1
override fun contains(value: LocalDate) = value in dates
override fun get(index: Int): Result<LocalDate> {
if (index < 0 || index > size) return Result.failure(IndexOutOfBoundsException())
return Result.success(dates.start.firstDayOfMonth + DatePeriod(months = index))
}
override fun section(range: ClosedRange<Int>): List<LocalDate> {
TODO("Not yet implemented")
}
override fun iterator(): Iterator<LocalDate> = object: Iterator<LocalDate> {
private var index = 0
override fun hasNext() = index < size
override fun next() = this@MonthModel[index++].getOrThrow()
}
}
//sampleStart
class CalendarApp(display: Display, themeManager: ThemeManager, theme: Theme): Application {
private val offsetMonths = 20
private val monthList by lazy {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val sharedSelectionModel = SingleItemSelectionModel<LocalDate>()
io.nacular.doodle.controls.list.List(
model = MonthModel(today - DatePeriod(months = offsetMonths)..today + DatePeriod(months = offsetMonths)),
itemVisualizer = itemVisualizer { item, previous, _ ->
when (previous) {
is NamedMonthPanel -> previous.also { it.monthPanel.setDate(item) }
else -> NamedMonthPanel(
item,
itemVisualizer { day, previous, panel ->
when (previous) {
is CalendarDay -> previous.apply { update(panel, day) }
else -> CalendarDay(today, day, panel)
}
},
selectionModel = sharedSelectionModel
)
}
},
).apply {
behavior = basicVerticalListBehavior(itemHeight = 300.0, numColumns = 1, evenItemColor = Color.Transparent, oddItemColor = Color.Transparent)
cellAlignment = fill
acceptsThemes = false
displayChange += { _, _, _ ->
scrollTo(offsetMonths)
}
}
}
init {
themeManager.selected = theme
display += object: View() {
init {
clipCanvasToBounds = false
val scrollPanel = ScrollPanel(monthList).apply {
suggestSize(Size(300))
contentWidthConstraints = { it eq width - verticalScrollBarWidth }
scrollBarDimensionsChanged += {
relayout()
}
}
children += listOf(DaysOfTheWeekPanel(), scrollPanel)
layout = constrain(children[0], children[1]) { header, scroll ->
header.top eq 0
header.width eq parent.width - scrollPanel.verticalScrollBarWidth
header.height eq 50
scroll.top eq header.bottom
scroll.width eq parent.width
scroll.bottom eq parent.bottom strength Strong
}
suggestBounds(Rectangle(300, 350))
Resizer(this, movable = false)
}
override fun render(canvas: Canvas) {
canvas.outerShadow(vertical = 10.0, blurRadius = 10.0, color = Black.opacity(0.5f)) {
rect(bounds.atOrigin, color = Color(0xccccccu).inverted)
}
}
}
display.layout = constrain(display.first(), center)
}
override fun shutdown() {}
}
//sampleEnd
This app uses many UI controls that can be styled using various behavior modules. The following shows how it is configured.
package controls.calendar
import io.nacular.doodle.application.application
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.drawing.Color.Companion.Transparent
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.text.StyledText
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicDaysOfTheWeekPanelBehavior
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicLabelBehavior
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicMonthPanelBehavior
import io.nacular.doodle.theme.native.NativeTheme.Companion.nativeScrollPanelBehavior
import org.kodein.di.instance
//sampleStart
fun main() {
val appModules = listOf(
basicLabelBehavior(),
basicMonthPanelBehavior(),
nativeScrollPanelBehavior(),
basicDaysOfTheWeekPanelBehavior(
background = Transparent.paint,
defaultVisualizer = itemVisualizer { day, previous, _ ->
val text = StyledText(day.name.take(1), foreground = White.paint)
when (previous) {
is Label -> previous.apply { styledText = text }
else -> Label(text)
}
}
)
)
application(modules = appModules) {
CalendarApp(
display = instance(),
themeManager = instance(),
theme = instance()
)
}
}
//sampleEnd
Form
Forms provide a way of collecting structured data from a user. This is generally quite complex given the wide range of visual representations, data types, and validation steps usually involved. Doodle simplifies this entire flow with a single control that offers full customization and type safety.
This example shows the use of validating text inputs, a radio list, and a sub form to gather some data about a person.
- Demo
- Usage
package controls
import controls.Gender.Female
import controls.Gender.Male
import io.nacular.doodle.controls.buttons.PushButton
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.LabeledConfig
import io.nacular.doodle.controls.form.TextFieldConfig
import io.nacular.doodle.controls.form.form
import io.nacular.doodle.controls.form.labeled
import io.nacular.doodle.controls.form.radioList
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.controls.form.verticalLayout
import io.nacular.doodle.controls.text.TextField.Purpose
import io.nacular.doodle.controls.text.TextField.Purpose.Integer
import io.nacular.doodle.controls.text.TextField.Purpose.Text
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Font
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.text.StyledText
import io.nacular.doodle.text.invoke
import io.nacular.doodle.utils.ToStringIntEncoder
enum class Gender { Male, Female }
fun form(smallFont: Font) {
fun <T> LabeledConfig.textFieldConfig(
placeHolder: String = "",
purpose : Purpose = Text,
errorText : StyledText? = null
): TextFieldConfig<T>.() -> Unit = {
val initialHelperText = help.styledText
help.font = smallFont
textField.placeHolder = placeHolder
textField.purpose = purpose
onValid = { help.styledText = initialHelperText }
onInvalid = {
if (!textField.hasFocus) {
help.styledText = errorText ?: it.message?.let { Red { it } } ?: help.styledText
}
}
}
val submit = PushButton("Submit").apply {
enabled = false
suggestSize(Size(100, 32))
}
val twoDigitNumber = Regex("^1[0-5]\\d|^[1-9]\\d|^[1-9]")
//sampleStart
val form = Form { this(
+labeled("Name", help = "3+ letters") {
textField(Regex(".{3,}"), config = textFieldConfig("Enter your name"))
},
+labeled("Age", help = "1 or 2 digit number") {
textField(twoDigitNumber, ToStringIntEncoder, config = textFieldConfig(purpose = Integer))
},
Female to labeled("Gender") { radioList(Male, Female) { spacing = 12.0 } },
+form { this(
+labeled("Text [Sub-form]", help = "Can be blank") {
textField(Regex(".*"), config = textFieldConfig())
},
+labeled("Number [Sub-form]", help = "1 to 10") {
textField(
twoDigitNumber,
ToStringIntEncoder,
validator = { it <= 10 },
config = textFieldConfig(purpose = Integer)
)
}
) { first, second ->
// nested Form creates a Pair<String, Int>
first to second
} },
onInvalid = { submit.enabled = false },
) { name, age, gender, pair ->
submit.enabled = true
println("[Form valid] Name: $name, Age: $age, Gender: $gender, Sub-form: $pair") // <---- check console for output
} }.apply {
// configure the Form view itself
suggestSize(Size(300, 100))
layout = verticalLayout(spacing = 12.0)
focusable = false
}
//sampleEnd
}
Form fields can bind to any type and use any View
for display. This is done using a FieldVisualizer
or the field
dsl.