Skip to main content


Doodle forms make data collection simple, while still preserving flexibility to build just the right experience. They hide a lot of the complexity associated with mapping visual components to fields, state management, and validation. The result is an intuitive metaphor modeled around the idea of a constructor.

Library Required

You will need to add the Controls library to your app's dependencies.


dependencies { implementation ("io.nacular.doodle:controls:$doodleVersion") }

Doodle also has a set of helpful forms controls that cover a reasonable range of data-types. These make its easy to create forms without much hassle. But there are bound to be cases where more customization is needed. This is why Doodle forms are also extensible, allowing you to fully customize the data they bind to and how each fields is visualized.

Like constructors

Forms are very similar to constructors in that they have typed parameter lists (fields), and can only "create" instances when all their inputs are valid. Like any constructor, a Form can have optional fields, default values, and arbitrary types for its fields.

While Forms behave like constructors in most ways, they do not actually create instances (only sub-forms do). This means they are not typed. Instead, they take fields and output a corresponding lists of strongly-typed data when all their fields are valid. This notification is intentionally general to allow forms to be used in a wide range of used cases.

Forms are created using the form DSL function. This function ensures strong typing for fields and the form's "output".


The Form returned from the builder does not expose anything about the data it produces. So all consumption logic goes in the builder block.

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


Each field defined in the Form will be bounded to a single View. These views are defined during field binding using a FieldVisualizer. A visualizer is responsible for taking a Field and its initial state and returning a View. The visualizer then acts as the bridge between the field's state and the View, mapping changes made in the View to the field (this includes validating that input).

Fields store their data as FieldState. This is a strongly-typed value that can be Valid or Invalid. Valid state contains a value, while invalid state does not. A Form with any invalid fields is invalid itself, and will indicate this by calling onInvalid.

Creating fields

Fields are created implicitly when FieldVisualizers are bound to a Form. These visualizers can be created using the Field DSL, by implementing the interface, or by one of the existing form controls.

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

Fields all have an optional initial value. Therefore, each field can be bounded either with a value or without one. The result is 2 different ways of adding a field to a Form.

The following shows how to bind fields that has no default value.

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 }

This shows how to bind using initial values.

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 }

These examples bind fields that have no names. Doodle has a labeled form control that wraps a control and assigns a name to it.


Note that a visualizer may set a field's state to some valid value at initialization time. This will give the same effect as that field having had a initial value specified that the visualizer accepted.

Forms as fields

Forms can also have nested forms within them. This is helpful when the field has complex data that can be presented to the user as a set of components. Such cases can be handled with custom visualizers, but many work well using a nested form.

Nested forms are created using the form DSL. It works just like the top-level Form DSL, but it actually creates an instance and has access to the initial value it is bound to (if any).


Nested forms can be used with or without initial values like any other field.


Forms allow you to position their fields via a Layout just like other containers. This positionable items seen by the layout will correspond to the fields bound to the Form in the same order. All the examples in this documentation use a vertical layout via the verticalLayout helper.

More than 5 fields?

The form DSLs have variants for up to 5 fields, as well as a version that takes an arbitrary number of fields, but does not provide strongly typed data back. This is by design. Forms are generally easier to use if they are shorter. Nonetheless, you can achieve this with a bit of extra work.

The recommended approach is to try and group related data together so you reduce the number of fields within your Forms. This is a good practice in general, since it defines the semantics of your data right when they are collected. It is also easy to achieve with nested form fields.

This app takes 2 top level fields for the user's name and contact info. Contact info this obtained using a sub form that produces an object. That object in turn contains a telephone and address. The latter is itself a data object that is obtained using a nested form as well.

Use list destructuring + casting

The other approach is using List destructuring to pull out the results of the untyped version of the form DSL. The form above can also be achieved by defining the 6 and 7 component destructors and then casting each element to the 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) -> println("Personal 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 )}") } } //sampleEnd }

This approach is sometimes more concise than the sub form approach, but it is discouraged b/c it introduces brittleness due to the casting.