Selection & Text
Coming from Sliders & Value Pickers, let's move from numeric values to text and lists of options.
Which one do you actually want?
| If you need to... | Reach for |
|---|---|
| Show static text | Label |
| Collect free-form text input | TextField |
| Store one choice from a fixed list of options | SelectBox |
| Trigger an action from a list of choices | Menu |
Label
The simplest control on this whole page, but not a plain one: it holds and displays text with real styling support. Reach for one anywhere you need a caption, a heading inside a layout, or text that a screen reader should associate with another control — see accessibilityLabelProvider on the Accessibility page for that last case.
The simplest control on this whole page: shows a piece of static text. Reach for one anywhere you need a caption, a heading inside a layout, or text that a screen reader should associate with another control — see accessibilityLabelProvider on the Accessibility page for that last case.
- Demo
- Usage
package label
import io.nacular.doodle.controls.text.Label
//sampleStart
val label = Label("Some Text")
//sampleEndBeyond plain text, a Label can hold StyledText for rich, per-span styling, wrap across multiple lines, align both horizontally and vertically, and adjust its own letter and line spacing — most of what you saw on the Text page, just packaged as a control instead of raw Canvas calls. It needs a LabelBehavior to render, same as everything else on this page.
- 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
}Label requires a LabelBehavior to render. These examples use basicLabelBehavior.
TextField
Single-line, editable text input — the one you'll use constantly. It manages its own focus automatically when styled with NativeTheme (Web, Desktop), which is why it was called out specifically back on User Input.
It's more configurable than it looks at first glance: set its purpose (Email, Telephone, a plain number, and so on) to get the platform-appropriate keyboard and input handling for free — this is exactly what the Telephone purpose was doing back in the Forms destructuring example, without it being explained there.
- Demo
- Usage
package textfield
import io.nacular.doodle.controls.text.TextField
//import io.nacular.doodle.controls.text.TextField.Purpose.Email
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
}
//sampleEndNeeds a TextFieldBehavior; the nativeTextFieldBehavior (Web, Desktop) module provides one.
It is customizable via NativeTextFieldBehaviorModifier if you want to change its look while keeping the platform's native text-editing behavior underneath.
- 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.lerp
import io.nacular.doodle.drawing.lighter
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.stroke
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), thickColor.stroke)
canvas.line(start = Point(0.0, textField.height - 1.0), end = Point(textField.width, textField.height - 1.0), color.stroke )
}
}
//sampleEndpackage 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.
SelectBox
Picks a single item from a list of options — a dropdown, in the presentation everyone expects. It's similar in spirit to SpinButton — both narrow a set of choices to one visible selection — but where SpinButton steps through values in place, SelectBox drops a whole list open when activated. Like SpinButton, its ListModel behaves like an Iterator, so the options don't all need to be loaded up front. It also takes two ItemVisualizers — one for how the selected item appears in the closed box, and a separate one for how each option appears in the open list.
- Demo
- Usage
- Customization
package controls
import io.nacular.doodle.controls.selectbox.SelectBox
//sampleStart
val selectBox1 = SelectBox(1..9 step 2)
val selectBox2 = SelectBox(listOf("Left", "Center", "Right"))
//sampleEndRendering requires a SelectBoxBehavior. BasicTheme provides one.
MutableSelectBox lets you change its values directly, or by updating its underlying model.
- 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
A different flavor of "pick one from a list" — items you click to trigger an action, or open a nested sub-menu, rather than a dropdown field. Note that Menu itself is just the vertical list of items as a View; it doesn't handle showing itself as a popup — that's a separate, easy step using Popups & Modals, covered right after this code sample.
You build one with the declarative DSL on an injected MenuFactory:
Include the MenuFactoryModule (Web, Desktop) in your application 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
}- Demo
- Usage
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.
This example adds a Menu straight to the Display as a top-level View. In practice, you'll often want one to appear on demand instead — anchored to a button, dismissed on an outside click. That's exactly what Popups & Modals is built for, and it's smart about it too: reposition the anchoring button around the screen, and a shown Menu adjusts where its popup opens to stay visible.
- 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.value - button.bottom > modal.height.value + 15 -> modal.bottom lessEq parent.bottom - 5
else -> modal.bottom lessEq button.y - 10
}
modal.size.preserve
}
}
}
}
Resizer(this)
}
//sampleEnd
}Move the button around to see how the Menu adjusts the location of its popups to keep them visible as much as possible.
Recap
Labelshows static text;TextFieldcollects editable text, and manages its own focus withNativeTheme(Web, Desktop).SelectBoxandMenuboth narrow a list down to one choice —SelectBoxwhen the options are a value to be stored,Menuwhen each option is really an action to trigger.
Next: on to Data Display for showing collections — lists, tables, and trees.