Forms
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.
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
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.
- 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
size = 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
size = Size(300, 100)
layout = verticalLayout(this, spacing = 12.0, itemHeight = 32.0)
focusable = false
}
//sampleEnd
}
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
Fields
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.
- 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
}
}
//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).
- 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 can be used with or without initial values like any other field.
Layouts
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.
Group related fields into objects
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.
- 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
}
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.