Forms
Back at the Catalog, every control stood alone. Forms are where several of them come together into one thing: a typed, validated unit for collecting data.
Include the Controls library to your app's dependencies.
dependencies {
implementation ("io.nacular.doodle:controls:$doodleVersion")
}Doodle ships a set of ready-made form controls covering a good range of data types — Form Controls is the full list — which makes it easy to put a form together without much fuss. And when the built-ins don't fit, forms are fully extensible: you can customize both the data a field binds to and how that field gets visualized.
- 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.AnnotatedString
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 : AnnotatedString? = null
): TextFieldConfig<T>.() -> Unit = {
val initialHelperText = help.annotatedText
help.font = smallFont
textField.placeHolder = placeHolder
textField.purpose = purpose
onValid = { help.annotatedText = initialHelperText }
onInvalid = {
if (!textField.hasFocus) {
help.annotatedText = errorText ?: it.message?.let { Red { it } } ?: help.annotatedText
}
}
}
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
}Like constructors
Here's the metaphor worth holding onto for everything that follows: a Form behaves a lot like a constructor. It has a typed parameter list (its fields), and it only "creates" its result once every one of those parameters is valid — optional fields, default values, arbitrary field types, all included.
The metaphor isn't perfect, and it's worth knowing exactly where it bends: a Form doesn't actually instantiate anything (only sub-forms do), so it isn't typed the way a real constructor is. Instead, it takes fields and hands you a corresponding list of strongly-typed values once everything checks out — general enough to cover a wide range of use cases.
Build one with the form DSL, which keeps both the fields and the form's eventual output strongly typed:
package forms
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.utils.ToStringIntEncoder
//sampleStart
val twoDigitNumber = Regex("^1[0-5]\\d|^[1-9]\\d|^[1-9]")
val form = Form { this(
"Mary" to textField(),
35 to textField(twoDigitNumber, ToStringIntEncoder),
// ...
onInvalid = {
// called whenever any fields is updated with invalid data
}) { name: String, age: Int, /*...*/ ->
// called each time all fields are updated with valid data
}
}
//sampleEnd
The Form returned from the builder doesn't expose anything about the data it produces — so all consumption logic belongs inside the builder block itself.
Fields
Every field in a Form binds to a single View. That binding happens through a FieldVisualizer, whose job is taking a Field and its initial state and handing back a View. From there, the visualizer acts as the bridge in both directions — mapping whatever changes in the View back onto the field, validation included.
Fields store their data as FieldState: a strongly-typed value that's either Valid (carrying a value) or Invalid (carrying nothing). A Form with even one invalid field is invalid as a whole, which is exactly what triggers onInvalid.
Creating fields
Fields spring into existence implicitly, the moment a FieldVisualizer gets bound to a Form. You can build one with the Field DSL, by implementing the interface directly, or — most of the time — by reaching for one of the existing form controls instead:
- DSL
- Interface
package forms
import io.nacular.doodle.controls.form.field
import io.nacular.doodle.core.view
fun <T> fieldDsl() {
//sampleStart
field<T> {
initial // initial state of the field
state // mutable state of the field
view {} // view to display for the field
}
//sampleEnd
}package forms
import io.nacular.doodle.controls.form.FieldInfo
import io.nacular.doodle.controls.form.FieldVisualizer
import io.nacular.doodle.core.View
import io.nacular.doodle.core.view
//sampleStart
class MyVisualizer<T>: FieldVisualizer<T> {
override fun invoke(fieldInfo: FieldInfo<T>): View {
fieldInfo.initial // initial state of the field
fieldInfo.state // mutable state of the field
return view {} // view to display for the field
}
}
//sampleEndField binding
Every field's initial value is optional, which gives you two ways to bind one to a Form. Here's binding without a default — notice the + prefix in place of a to pair:
package forms
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.field
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.core.view
import io.nacular.doodle.utils.ToStringIntEncoder
fun <T> noDefaults() {
//sampleStart
data class Person(val name: String, val age: Int)
val form = Form { this(
+ textField(),
+ textField(encoder = ToStringIntEncoder),
+ field<Person> { view {} },
// ...
onInvalid = {}
) { text: String, number: Int, person: Person ->
// ...
} }
//sampleEnd
}
And here's the same form, this time with initial values supplied for every field:
package forms
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.field
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.core.view
import io.nacular.doodle.utils.ToStringIntEncoder
fun <T> withDefaults() {
//sampleStart
data class Person(val name: String, val age: Int)
val form = Form { this(
"Hello" to textField(),
4 to textField(encoder = ToStringIntEncoder),
Person("Jack", 55) to field { view {} },
// ...
onInvalid = {}
) { text: String, number: Int, person: Person ->
// ...
} }
//sampleEnd
}
Both examples bind fields with no name attached. Doodle's labeled form control wraps any control and gives it one.
A visualizer can set a field's state to a valid value right at initialization — the effect is identical to that field having been given an initial value in the first place.
Forms as fields
A Form can nest other forms inside it, which comes in handy whenever a field's data is complex enough to deserve its own little cluster of controls. Custom visualizers can handle that too, but a nested form is often the simpler route.
Build a nested form with the same form DSL — it works just like the top-level version, except this time it genuinely does create an instance, and has access to whatever initial value it's bound to.
- Demo
- Usage
package forms
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.form
import io.nacular.doodle.controls.form.labeled
import io.nacular.doodle.controls.form.map
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.controls.text.TextField.Purpose.Integer
import io.nacular.doodle.controls.text.TextField.Purpose.Telephone
import io.nacular.doodle.utils.ToStringIntEncoder
fun <T> formFields() {
//sampleStart
data class Person(val name: String, val age: Int)
val form = Form { this(
+ labeled("Text" ) { textField( ) },
+ labeled("Telephone") { textField(encoder = ToStringIntEncoder) { textField.purpose = Telephone } },
Person("Jack", 55) to form { this(
initial.map { it.name } to labeled("Name") { textField( ) },
initial.map { it.age } to labeled("Age" ) { textField(encoder = ToStringIntEncoder) { textField.purpose = Integer } },
onInvalid = {}
) { name, age ->
Person(name, age) // construct person when valid
} },
// ...
onInvalid = {}
) { text: String, number: Int, person: Person ->
// called each time all fields are updated with valid data
} }
//sampleEnd
}Nested forms take initial values (or not) exactly like any other field.
Layouts
A Form positions its fields through a Layout, the same way any other container does — the positionable items that Layout sees correspond, in order, to the fields bound to the form.
Every example on this page uses a simple vertical layout, via the verticalLayout helper.
You're not limited to that, though — a form's fields can be positioned with the exact same constraints you'd use anywhere else, declared right alongside the fields themselves:
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.labeled
import io.nacular.doodle.controls.form.spinButton
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.core.Behavior
import io.nacular.doodle.core.Display
import io.nacular.doodle.docs.utils.BlueColor
import io.nacular.doodle.docs.utils.colorStrip
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.Blue
import io.nacular.doodle.drawing.Color.Companion.Green
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.lighter
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.layout.Insets
import io.nacular.doodle.layout.constraints.Strength.Companion.Required
import io.nacular.doodle.layout.constraints.Strength.Companion.Strong
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.ideal
import io.nacular.doodle.layout.constraints.minus
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.utils.Direction.East
import io.nacular.doodle.utils.Direction.West
import io.nacular.doodle.utils.Resizer
class FormLayoutApp(display: Display, themeManager: ThemeManager, theme: Theme): Application {
init {
themeManager.selected = theme
//sampleStart
val form = Form { this (
"Bob" to labeled("Name" ) { textField ( ) },
21 to labeled("Age" ) { spinButton(1..120 ) },
Green to labeled("Color") { colorStrip(Red, Green, Blue, BlueColor, Black) },
// define constraint layout for form with fields in declaration order
layout = { name, age, color ->
name.top eq parent.insets.top
name.left eq parent.insets.left
name.right eq age.left - 12 strength Strong
name.height prefers ideal
age.top eq name.top
age.width eq 80
age.right eq parent.right - parent.insets.right
age.height prefers ideal
color.top eq readonly(name.bottom) + 12
color.left eq name.top
color.right eq age.right strength Strong
color.height prefers ideal
parent.bottom eq readonly(color.bottom) + parent.insets.bottom strength Required - 1
},
onInvalid = {}
) { name: String, color: Int, age: Color ->
// ...
} }
//sampleEnd
form.apply {
insets = Insets(12.0)
focusable = false
behavior = object: Behavior<Form> {
override fun render(view: Form, canvas: Canvas) {
canvas.rect(view.bounds.atOrigin, Lightgray.lighter(0.75f).paint)
}
}
suggestWidth(300.0)
Resizer(this, movable = false).apply { directions = setOf(East, West) }
}
display += form
display.layout = constrain(form) {
it.width prefers it.width.value
it.height prefers ideal
it.center eq parent.center
}
display.fill(White.paint)
}
override fun shutdown() {}
}
More than 5 fields?
The form DSLs come in variants for up to 5 fields, plus one final version that accepts any number of fields but gives up strongly-typed data in return. That's a deliberate tradeoff — forms tend to be easier to work with when they're short — but a couple of escape hatches exist for when you genuinely need more.
Group related fields into objects
The recommended one: group related data together so your form has fewer top-level fields to begin with. This is good practice regardless of field count, since it pins down the semantics of your data right at the point you collect it — and nested form fields make it easy to pull off.
- Demo
- Usage
package forms
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.form
import io.nacular.doodle.controls.form.labeled
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.controls.text.TextField.Purpose.Telephone
fun groupingFields() {
data class Address(
val number: String,
val street: String,
val city : String,
val state : String,
val zip : String
)
data class ContactInfo(
val telephone: String,
val address : Address,
)
data class PersonalInfo(
val name : String,
val contact: ContactInfo
)
val notEmpty = Regex(".+")
//sampleStart
val form = Form { this(
+ labeled("Name") { textField(notEmpty) },
+ form { this(
+ labeled("Telephone") { textField(notEmpty) { textField.purpose = Telephone } },
+ form<Address> { this(
+ labeled("Number") { textField(notEmpty) },
+ labeled("Street") { textField(notEmpty) },
+ labeled("City" ) { textField(notEmpty) },
+ labeled("State" ) { textField(notEmpty) },
+ labeled("Zip" ) { textField(notEmpty) },
) { number, street, city, state, zip ->
Address(number, street, city, state, zip)
} },
) { telephone, address ->
ContactInfo(telephone, address)
} },
onInvalid = {}
) { name, contact ->
println("Personal Info: ${PersonalInfo(name, contact)}")
} }
//sampleEnd
}
Two top-level fields (name and ContactInfo), where ContactInfo is itself a sub-form producing an object containing a telephone and address — and address, in turn, comes from a nested form of its own.
Use list destructuring + casting
The other option: pull results out of the untyped Form DSL variant using List destructuring. The same form above can be built this way too, by defining the 6th and 7th component destructuring operators and casting each element to its correct type:
package forms
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.labeled
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.controls.text.TextField.Purpose.Telephone
fun destructuring() {
data class PersonalInfo(
val name : String,
val telephone: String,
val number : String,
val street : String,
val city : String,
val state : String,
val zip : String
)
val notEmpty = Regex(".+")
//sampleStart
operator fun <E> List<E>.component6() = this[5]
operator fun <E> List<E>.component7() = this[6]
val form = Form { this(
+ labeled("Name" ) { textField(notEmpty) },
+ labeled("Telephone") { textField(notEmpty) { textField.purpose = Telephone } },
+ labeled("Number" ) { textField(notEmpty) },
+ labeled("Street" ) { textField(notEmpty) },
+ labeled("City" ) { textField(notEmpty) },
+ labeled("State" ) { textField(notEmpty) },
+ labeled("Zip" ) { textField(notEmpty) },
onInvalid = {},
) { (name, telephone, number, street, city, state, zip) ->
val info = PersonalInfo(
name as String, // Cast required for all fields
telephone as String,
number as String,
street as String,
city as String,
state as String,
zip as String
)
println("Personal Info: $info")
} }
//sampleEnd
}
This approach is sometimes more concise than the sub-form route, but it's discouraged — the casting it requires introduces a real source of brittleness that grouping into objects simply doesn't have.