From 6dcf977b47a7321fc41d6b1fe989d4a5d496a89e Mon Sep 17 00:00:00 2001 From: Gary Bezruchko Date: Wed, 27 Aug 2025 16:54:17 +0300 Subject: [PATCH] Fix recomposition problems --- .../kotlin/dev/voir/formica/Formica.kt | 1 + .../dev/voir/formica/ui/FormicaField.kt | 44 ++++++++++----- .../kotlin/dev/voir/formica/sample/App.kt | 53 ++++++++++++++++--- 3 files changed, 78 insertions(+), 20 deletions(-) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt b/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt index b390c12..3145490 100644 --- a/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt +++ b/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt @@ -71,6 +71,7 @@ class Formica(val initialData: Data, private val onSubmit: ((Data) -> Unit validateOnChange = validateOnChange ) // Store in registry with erased generics + @Suppress("UNCHECKED_CAST") fields[id.id] = (id as FormicaFieldId) to (field as FormicaField) return field diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt index 13a6873..4c6bfab 100644 --- a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt +++ b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt @@ -1,7 +1,6 @@ package dev.voir.formica.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -10,6 +9,8 @@ import dev.voir.formica.Formica import dev.voir.formica.FormicaFieldId import dev.voir.formica.FormicaFieldResult import dev.voir.formica.ValidationRule +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map /** * A stable snapshot of a form field's UI-facing state and operations. @@ -87,13 +88,6 @@ fun FormicaField( val touched by field.touched.collectAsState(initial = false) val dirty by field.dirty.collectAsState(initial = false) - // Optional: Keep field state in sync if form.data changes externally (e.g., loaded draft). - // This resets the field's internal state (value/error/touched/dirty) to match the new snapshot. - val dataSnapshot by form.data.collectAsState() - LaunchedEffect(dataSnapshot) { - field.reset(id.get(dataSnapshot)) - } - // Package the reactive state and callbacks into a stable adapter for the UI. val adapter = remember(value, error, touched, dirty) { FieldAdapter( @@ -150,12 +144,38 @@ fun FormicaField( * reactively (without registering a field). * * Returns the current value of the field from the immutable form data snapshot. - * Will recompose whenever [form.data] changes. */ @Composable -fun rememberFormicaFieldValue(form: Formica, id: FormicaFieldId): V? { - val data by form.data.collectAsState() - return id.get(data) +fun rememberFormicaFieldValue( + form: Formica, + id: FormicaFieldId, + // Optional comparator (useful for floats with epsilon) + areEquivalent: (V?, V?) -> Boolean = { a, b -> a == b } +): V? { + // If the field is registered, prefer its own StateFlow (cheapest & already scoped) + val registered = remember(form, id) { form.getRegisteredField(id) } + val initial = remember(form, id) { id.get(form.data.value) } + + return if (registered != null) { + registered.value.collectAsState(initial = initial).value + } else { + // Project the form snapshot to just this field and suppress identical emissions + val projected = remember(form, id, areEquivalent) { + form.data + .map { id.get(it) } + .distinctUntilChanged { old, new -> areEquivalent(old, new) } + } + projected.collectAsState(initial = initial).value + } +} + +@Composable +fun rememberFormicaFieldValue( + id: FormicaFieldId, + areEquivalent: (V?, V?) -> Boolean = { a, b -> a == b } +): V? { + val form = formicaOf() + return rememberFormicaFieldValue(form, id, areEquivalent) } /** diff --git a/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt b/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt index 29208e1..ca53438 100644 --- a/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt +++ b/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt @@ -36,7 +36,8 @@ import dev.voir.formica.ui.rememberFormicaFieldValue data class FormSchema( var text: String, - var number: Int?, + var numberRequired: Int, + var numberOptional: Int?, var optionalText: String?, var activateAdditionalText: Boolean, var additionalText: String? = null, @@ -47,10 +48,16 @@ val MainText = FormicaFieldId( get = { it.text }, set = { d, v -> d.copy(text = v) } ) -val Number = FormicaFieldId( - id = "number", - get = { it.number }, - set = { d, v -> d.copy(number = v) } +val NumberRequired = FormicaFieldId( + id = "numberRequired", + get = { it.numberRequired }, + set = { d, v -> d.copy(numberRequired = v) } +) + +val NumberOptional = FormicaFieldId( + id = "numberOptional", + get = { it.numberOptional }, + set = { d, v -> d.copy(numberOptional = v) } ) val OptionalText = FormicaFieldId( @@ -80,7 +87,8 @@ fun App() { val formica = rememberFormica( initialData = FormSchema( text = "", - number = null, + numberRequired = 0, + numberOptional = null, optionalText = null, activateAdditionalText = false, additionalText = null, @@ -150,9 +158,38 @@ fun App() { } } + // Number (required and must be >= 10 if provided) + FormicaField( + id = NumberRequired, + validators = setOf( + ValidationRules.required(), + ValidationRules.min(10) + ) + ) { field -> + FormFieldWrapper { + TextField( + modifier = Modifier.fillMaxWidth(), + value = field.value?.toString().orEmpty(), + label = { + Text("Number (required and >= 10)") + }, + placeholder = { + Text("1234") + }, + onValueChange = { s -> + field.onChange(s.toIntOrNull()) + } + ) + + if (field.error != null) { + Text(field.error!!, color = Color.Red) + } + } + } + // Number (optional, but must be >= 0 if provided) FormicaField( - id = Number, + id = NumberOptional, validators = setOf( ValidationRule { v -> if (v == null) FormicaFieldResult.Success @@ -166,7 +203,7 @@ fun App() { modifier = Modifier.fillMaxWidth(), value = field.value?.toString().orEmpty(), label = { - Text("Number text") + Text("Number (optional, but >= 0 if provided)") }, placeholder = { Text("1234")