Skip to main content

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.

Library Required

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.

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
info

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:

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 }

Field 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 }
tip

Both examples bind fields with no name attached. Doodle's labeled form control wraps any control and gives it one.

info

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.

tip

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.

tip

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.

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.

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 }
caution

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.