
Declarative DSL to define forms once with fields, validation, conditions, navigation and phone-number support; renders with native UI adapters, keyboard navigation, selection presentations and shared styling.
A Kotlin Multiplatform form library. You define a form once in shared Kotlin: fields, groups, validation, conditions and submission. It then renders natively on Android with Compose, and on iOS with either Compose Multiplatform or native SwiftUI. Kotlin keeps the state and the rules; each renderer only draws the platform controls.
| Artifact | Contents |
|---|---|
io.github.robinpcrd:kotose-form-core |
Renderer-neutral model: Form, groups, fields, validation, conditions, navigation and the DSL |
io.github.robinpcrd:kotose-form-phone |
Phone number field and countries; without a backend it takes a typed dial code and number |
io.github.robinpcrd:kotose-form-phone-backend |
Optional: country picker, parsing, validation and formatting with libphonenumber on Android and PhoneNumberKit on iOS |
io.github.robinpcrd:kotose-form |
Compose renderer (FormScreen) for Android and iOS, styled fields and the legacy Compose DSL |
io.github.robinpcrd:kotose-form-swiftui |
Native SwiftUI renderer (KotoseNativeForm) and its Kotlin adapter (NativeFormAdapter), experimental |
KotoseFormStyle on both renderers.Compose on Android and iOS:
commonMain.dependencies {
implementation("io.github.robinpcrd:kotose-form:<version>")
}Native SwiftUI uses the SwiftUI artifact, which brings the core and phone modules but not Compose. Add it to the shared KMP module that builds the iOS framework; that module applies SKIE, builds one static framework and exports the SwiftUI artifact:
plugins { id("co.touchlab.skie") }
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
isStatic = true
export("io.github.robinpcrd:kotose-form-swiftui:<version>")
}
}
sourceSets.commonMain.dependencies {
api("io.github.robinpcrd:kotose-form-swiftui:<version>")
}
}embedAndSignAppleFrameworkForXcode.kotose-form-swiftui alone is enough. A compile error in
KotoseNativeForm.swift is a bug: please report it.Only the optional phone backend needs a Swift package (PhoneNumberKit), through Kotlin's SwiftPM import, which is still Alpha. Connect it to your Xcode project once:
XCODEPROJ_PATH=/path/to/iosApp.xcodeproj ./gradlew :shared:integrateLinkagePackagegradle in its script. A phase that only
calls a wrapper script fails with "embedAndSign integration wasn't found"; mentioning
gradle in the phase, even in a comment, fixes it.project.pbxproj in Xcode's canonical format, so expect a large formatting diff.swiftPMDependencies { iosMinimumDeploymentTarget }
for another minimum.Phone fields work without extra dependencies: the row takes a dial code, after a fixed +,
and the number, as digits. For the country picker, per-country validation and formatting as
the user types, add the backend and set it once at startup, before building forms:
commonMain.dependencies {
implementation("io.github.robinpcrd:kotose-form-phone-backend:<version>")
}PhoneNumberBackend.current = LibPhoneNumberBackendOn iOS the backend needs the Swift package step from iOS setup; apps without it skip that step. See Phone numbers for what each mode does.
Define the form in shared Kotlin:
data class Plan(val code: Int)
val signUp = form {
+group {
+displayText(text = StrRes(text = "Create an account"), isTitle = true)
+textField(tag = "name", label = StrRes(text = "Name")) { addRule(RequiredFieldRule()) }
+secureTextField(tag = "password", label = StrRes(text = "Password"))
+toggleField(tag = "terms", label = StrRes(text = "Accept the terms"))
}
+singleSelectionGroup<Plan> {
tag = "plan"
title = StrRes(text = "Plan")
presentation = SelectionPresentation.Menu
+selectionOption("basic", Plan(1), StrRes(text = "Basic"))
+selectionOption("pro", Plan(2), StrRes(text = "Pro"))
}
}
// On submit: an empty list means valid, and values are keyed by tag.
val errors = signUp.validate()
val values = signUp.allValues()Render it with Compose:
@Composable
fun SignUpScreen() {
FormScreen(form = signUp)
}Or with SwiftUI, from a NativeFormAdapter(signUp) created in Kotlin. The adapter is
experimental: opt in with @OptIn(ExperimentalKotoseSwiftUIApi::class).
struct SignUpView: View {
let adapter: NativeFormAdapter
var body: some View {
ScrollViewReader { proxy in
ScrollView {
KotoseNativeForm(adapter: adapter, scrollProxy: proxy) { _, _ in AnyView(EmptyView()) }
.padding(.horizontal)
}
}
}
}The closure draws custom fields; see Custom fields. A selection shown
as a page needs a NavigationStack around the form.
The example/ app shows every field on the three renderers, with a Compose and SwiftUI switch
on each page; see Contributing to run it.
See CONTRIBUTING.md for build, test, and contribution guidelines.
Copyright 2025-present Robin PICARD and contributors.
Licensed under the Apache License, Version 2.0.
Originally created at PandaLab.
A Kotlin Multiplatform form library. You define a form once in shared Kotlin: fields, groups, validation, conditions and submission. It then renders natively on Android with Compose, and on iOS with either Compose Multiplatform or native SwiftUI. Kotlin keeps the state and the rules; each renderer only draws the platform controls.
| Artifact | Contents |
|---|---|
io.github.robinpcrd:kotose-form-core |
Renderer-neutral model: Form, groups, fields, validation, conditions, navigation and the DSL |
io.github.robinpcrd:kotose-form-phone |
Phone number field and countries; without a backend it takes a typed dial code and number |
io.github.robinpcrd:kotose-form-phone-backend |
Optional: country picker, parsing, validation and formatting with libphonenumber on Android and PhoneNumberKit on iOS |
io.github.robinpcrd:kotose-form |
Compose renderer (FormScreen) for Android and iOS, styled fields and the legacy Compose DSL |
io.github.robinpcrd:kotose-form-swiftui |
Native SwiftUI renderer (KotoseNativeForm) and its Kotlin adapter (NativeFormAdapter), experimental |
KotoseFormStyle on both renderers.Compose on Android and iOS:
commonMain.dependencies {
implementation("io.github.robinpcrd:kotose-form:<version>")
}Native SwiftUI uses the SwiftUI artifact, which brings the core and phone modules but not Compose. Add it to the shared KMP module that builds the iOS framework; that module applies SKIE, builds one static framework and exports the SwiftUI artifact:
plugins { id("co.touchlab.skie") }
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
isStatic = true
export("io.github.robinpcrd:kotose-form-swiftui:<version>")
}
}
sourceSets.commonMain.dependencies {
api("io.github.robinpcrd:kotose-form-swiftui:<version>")
}
}embedAndSignAppleFrameworkForXcode.kotose-form-swiftui alone is enough. A compile error in
KotoseNativeForm.swift is a bug: please report it.Only the optional phone backend needs a Swift package (PhoneNumberKit), through Kotlin's SwiftPM import, which is still Alpha. Connect it to your Xcode project once:
XCODEPROJ_PATH=/path/to/iosApp.xcodeproj ./gradlew :shared:integrateLinkagePackagegradle in its script. A phase that only
calls a wrapper script fails with "embedAndSign integration wasn't found"; mentioning
gradle in the phase, even in a comment, fixes it.project.pbxproj in Xcode's canonical format, so expect a large formatting diff.swiftPMDependencies { iosMinimumDeploymentTarget }
for another minimum.Phone fields work without extra dependencies: the row takes a dial code, after a fixed +,
and the number, as digits. For the country picker, per-country validation and formatting as
the user types, add the backend and set it once at startup, before building forms:
commonMain.dependencies {
implementation("io.github.robinpcrd:kotose-form-phone-backend:<version>")
}PhoneNumberBackend.current = LibPhoneNumberBackendOn iOS the backend needs the Swift package step from iOS setup; apps without it skip that step. See Phone numbers for what each mode does.
Define the form in shared Kotlin:
data class Plan(val code: Int)
val signUp = form {
+group {
+displayText(text = StrRes(text = "Create an account"), isTitle = true)
+textField(tag = "name", label = StrRes(text = "Name")) { addRule(RequiredFieldRule()) }
+secureTextField(tag = "password", label = StrRes(text = "Password"))
+toggleField(tag = "terms", label = StrRes(text = "Accept the terms"))
}
+singleSelectionGroup<Plan> {
tag = "plan"
title = StrRes(text = "Plan")
presentation = SelectionPresentation.Menu
+selectionOption("basic", Plan(1), StrRes(text = "Basic"))
+selectionOption("pro", Plan(2), StrRes(text = "Pro"))
}
}
// On submit: an empty list means valid, and values are keyed by tag.
val errors = signUp.validate()
val values = signUp.allValues()Render it with Compose:
@Composable
fun SignUpScreen() {
FormScreen(form = signUp)
}Or with SwiftUI, from a NativeFormAdapter(signUp) created in Kotlin. The adapter is
experimental: opt in with @OptIn(ExperimentalKotoseSwiftUIApi::class).
struct SignUpView: View {
let adapter: NativeFormAdapter
var body: some View {
ScrollViewReader { proxy in
ScrollView {
KotoseNativeForm(adapter: adapter, scrollProxy: proxy) { _, _ in AnyView(EmptyView()) }
.padding(.horizontal)
}
}
}
}The closure draws custom fields; see Custom fields. A selection shown
as a page needs a NavigationStack around the form.
The example/ app shows every field on the three renderers, with a Compose and SwiftUI switch
on each page; see Contributing to run it.
See CONTRIBUTING.md for build, test, and contribution guidelines.
Copyright 2025-present Robin PICARD and contributors.
Licensed under the Apache License, Version 2.0.
Originally created at PandaLab.