dokar3 / ChipTextField

Editable chip layout for Compose Multiplatform
Apache License 2.0
82 stars 3 forks source link

Trying to keep List<String> and ChipTextFieldState in sync #109

Closed SteffoSpieler closed 7 months ago

SteffoSpieler commented 7 months ago

(whoops, pressed Enter accidentally 😅)

I'm currently making an app for a school project and using ChipTextField as it looks awesome! The basic app is almost like the Inventory App Example from the Compose Tutorial.

Please keep in mind, I'm quite new to Kotlin / Jetpack Compose / Android development.

My code (Screen):

        val chipState = rememberChipTextFieldState<Chip>()
        var syncTags by remember { mutableStateOf(false) }
        OutlinedChipTextField(
            state = chipState,
            onSubmit = {
                syncTags = true
                Chip(it)
            },
            enabled = enabled,
            label = { Text(stringResource(R.string.form_field_tags_header)) },
        )
        DisposableEffect(chipState.chips, syncTags) {
            if (syncTags) {
                appointmentDetails.tags = chipState.chips.map { it.text }
                syncTags = false
            }

            onDispose {
                appointmentDetails.tags = chipState.chips.map { it.text }
            }
        }

My code (ViewModel):

data class AppointmentDetails(
    val id: Int = 0,
    val title: String = "",
    val description: String = "",
    val date: LocalDate = LocalDate.now(),
    val time: LocalTime = LocalTime.now().noSeconds(),
    var tags: List<String> = listOf()
)

My issue is, I want to have my tags all saved in the appointmentDetails.tags list. But as I didn't found out how to make that with the ChipTextFieldState, I had to ask ChatGPT, which came up with DisposableEffect. This works... kinda. When entering stuff, it works! But when editing something (loading from the appointmentDetails.tags), it doesn't show anything.

So, my question: Is there a way to somehow keep the ChipTextFieldState in sync with the tags List?

dokar3 commented 7 months ago

Hi, there.

The only missing is passing your initial tags to the rememberChipTextFieldState(chips = ...). And the DisposableEffect() can be simplified.

@Composable
fun YourScreen(
    details: AppointmentDetails,
    modifier: Modifier = Modifier,
) {
    val initialChips = remember { details.tags.map(::Chip) }
    val chipState = rememberChipTextFieldState(chips = initialChips)

    DisposableEffect(details, chipState.chips) {
        onDispose {
            // Update your view model tag list after updating the chips or leaving the screen
            details.tags = chipState.chips.map { it.text }
            // Or using the immutable data pattern instead of updating
            // the tags field directly:
            // viewModel.updateDetailsTags(chipState.chips.map { it.text })
        }
    }

    OutlinedChipTextField(
        state = chipState,
        onSubmit = { Chip(it) },
        modifier = modifier,
    )
}
dokar3 commented 7 months ago

You can also use a LaunchEffect() instead of DisposableEffect(), this will update your tags more frequently.

LaunchedEffect(chipState) {
    // Listen to chip content text changes too, no need 
    // to save tags when leaving the screen
    snapshotFlow { chipState.chips.map { it.text } }
        .collect { details.tags = it }
}
SteffoSpieler commented 7 months ago

Thanks for your quick response!

I tried both of your code blocks and.. well..

The Disposable Effect:

The LaunchedEffect:

Setting the initial tags still doesn't work sadly.

I know you don't have to help me - since it technically doesn't have something to do with your library (I guess?). If you need more context from the code, I have it here.

dokar3 commented 7 months ago

Oh, that sounds quirky...

LaunchEffect and DisposableEffect should work well when passing chipState.chips as the key, maybe you can use onValueChange as another key, or print some logs to see if it works.

LaunchedEffect(onValueChange, chipState.chips) {}
SteffoSpieler commented 7 months ago

oh wait, this works!!

        LaunchedEffect(onValueChange, chipState.chips) {
            // Listen to chip content text changes too, no need
            // to save tags when leaving the screen
            snapshotFlow { chipState.chips.map { it.text } }
                .collect { appointmentDetails.tags = it }
        }

..except now the date get's reset o.o and filling on edit doesn't work either.

Sadly I still don't understand much of what I'm doing there. I know many other languages, but this is new to me, as I already said. I'm really sorry for stealing you time for this 😓

dokar3 commented 7 months ago

You may need another LaunchedEffect key, appointmentDetails

SteffoSpieler commented 7 months ago

that now crashes the app:

FATAL EXCEPTION: arch_disk_io_2
Process: dev.steffo.terminkalenderapp, PID: 9171
java.lang.IllegalStateException: Method setCurrentState must be called on the main thread
    at androidx.lifecycle.LifecycleRegistry.enforceMainThreadIfNeeded(LifecycleRegistry.kt:296)
    at androidx.lifecycle.LifecycleRegistry.setCurrentState(LifecycleRegistry.kt:105)
    at androidx.navigation.NavBackStackEntry.updateState(NavBackStackEntry.kt:188)
    at androidx.navigation.NavBackStackEntry.setMaxLifecycle(NavBackStackEntry.kt:159)
    at androidx.navigation.NavController.popEntryFromBackStack(NavController.kt:761)
    at androidx.navigation.NavController.access$popEntryFromBackStack(NavController.kt:68)
    at androidx.navigation.NavController$executePopOperations$1.invoke(NavController.kt:643)
    at androidx.navigation.NavController$executePopOperations$1.invoke(NavController.kt:640)
    at androidx.navigation.NavController$NavControllerNavigatorState.pop(NavController.kt:330)
    at androidx.navigation.NavigatorState.popWithTransition(NavigatorState.kt:149)
    at
androidx.navigation.NavController$NavControllerNavigatorState.popWithTransition(NavController.kt:343)
    at androidx.navigation.compose.ComposeNavigator.popBackStack(ComposeNavigator.kt:67)
    at androidx.navigation.NavController.popBackStackInternal(NavController.kt:280)
    at androidx.navigation.NavController.executePopOperations(NavController.kt:640)
    at androidx.navigation.NavController.popBackStackInternal(NavController.kt:578)
    at androidx.navigation.NavController.popBackStack(NavController.kt:491)
    at androidx.navigation.NavController.popBackStack(NavController.kt:468)
    at androidx.navigation.NavController.popBackStack(NavController.kt:453)
    at
dev.steffo.terminkalenderapp.ui.navigation.TerminkalenderNavGraphKt$TerminkalenderNavHost$5$2$1.invoke(TerminkalenderNavGraph.kt:58)
    at
dev.steffo.terminkalenderapp.ui.navigation.TerminkalenderNavGraphKt$TerminkalenderNavHost$5$2$1.invoke(TerminkalenderNavGraph.kt:57)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.tools.deploy.liveedit.AndroidEval.invokeMethod(AndroidEval.java:316)
    at com.android.tools.deploy.liveedit.Fix226201991Eval.invokeMethod(Fix226201991Eval.java:28)
    at com.android.tools.deploy.liveedit.BackPorterEval.invokeMethod(BackPorterEval.java:124)
    at com.android.tools.deploy.liveedit.ProxyClassEval.invokeMethod(ProxyClassEval.java:164)
    at com.android.tools.deploy.liveedit.AndroidEval.invokeInterface(AndroidEval.java:289)
    at com.android.tools.deploy.liveedit.Fix226201991Eval.invokeInterface(Fix226201991Eval.java:28)
    at com.android.tools.deploy.liveedit.ProxyClassEval.invokeInterface(ProxyClassEval.java:148)
    at com.android.tools.deploy.interpreter.OpcodeInterpreter.naryOperation(OpcodeInterpreter.java:702)
    at com.android.tools.deploy.interpreter.OpcodeInterpreter.naryOperation(OpcodeInterpreter.java:182)
    at com.android.deploy.asm.tree.analysis.Frame.executeInvokeInsn(Frame.java:648)
    at com.android.deploy.asm.tree.analysis.Frame.execute(Frame.java:573)
    at
com.android.tools.deploy.interpreter.ByteCodeInterpreter.doInterpret(ByteCodeInterpreter.java:191)
    at
com.android.tools.deploy.interpreter.ByteCodeInterpreter.interpreterLoop(ByteCodeInterpreter.java:130)
    at com.android.tools.deploy.liveedit.MethodBodyEvaluator.eval(MethodBodyEvaluator.java:104)
    at com.android.tools.deploy.liveedit.LiveEditClass.invokeDeclaredMethod(LiveEditClass.java:107)
    at com.android.tools.deploy.liveedit.ProxyClassHandler.invokeMethod(ProxyClassHandler.java:94)
    at
com.android.tools.deploy.liveedit.LiveEditSuspendLambda.invokeSuspend(LiveEditSuspendLambda.java:33)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.tools.deploy.liveedit.ProxyClassHandler.invokeMethod(ProxyClassHandler.java:99)
    at com.android.tools.deploy.liveedit.ProxyClassHandler.invoke(ProxyClassHandler.java:126)
    at java.lang.reflect.Proxy.invoke(Proxy.java:1006)
    at $Proxy6.resumeWith(Unknown Source)
    at
kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:281)
    at
kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith$default(DispatchedContinuation.kt:276)
    at kotlinx.coroutines.DispatchedCoroutine.afterResume(Builders.common.kt:258)
    at kotlinx.coroutines.AbstractCoroutine.resumeWith(AbstractCoroutine.kt:102)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:46)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    at androidx.room.TransactionExecutor.execute$lambda$1$lambda$0(TransactionExecutor.kt:36)
    at androidx.room.TransactionExecutor.$r8$lambda$AympDHYBb78s7_N_9gRsXF0sHiw(Unknown Source:0)
    at androidx.room.TransactionExecutor$$ExternalSyntheticLambda0.run(Unknown Source:4)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)
    at java.lang.Thread.run(Thread.java:1012)
    Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [androidx.compose.ui.platform.MotionDurationScaleImpl@1676fbd, androidx.compose.runtime.BroadcastFrameClock@64459b2, StandaloneCoroutine{Cancelling}@1a9d03, AndroidUiDispatcher@a7df480]
dokar3 commented 7 months ago

Damn, this is real Compose...hard to find useful stack trace

dokar3 commented 7 months ago

These two lines are not likely to relate to the LaunchEffect.

    at
dev.steffo.terminkalenderapp.ui.navigation.TerminkalenderNavGraphKt$TerminkalenderNavHost$5$2$1.invoke(TerminkalenderNavGraph.kt:58)
    at
dev.steffo.terminkalenderapp.ui.navigation.TerminkalenderNavGraphKt$TerminkalenderNavHost$5$2$1.invoke(TerminkalenderNavGraph.kt:57)

Maybe you need to track that call to see if there is something wrong.

SteffoSpieler commented 7 months ago

hm.. That's just:

(57)        AppointmentEntryScreen(
(58)            navigateBack = { navController.popBackStack() },
                onNavigateUp = { navController.navigateUp() }
            )

So the point, where the Screen get's "rendered"? I have no idea though...

dokar3 commented 7 months ago

Weird...this call should be in the main thread.

https://git.steffo.dev/SteffoSpieler/terminkalender-app/src/commit/ffd13c8a313eef18daaf5e8d16115eb220a1eeee/app/src/main/java/dev/steffo/terminkalenderapp/ui/appointment/AppointmentEntryScreen.kt#L98

SteffoSpieler commented 7 months ago

ok well, I've rolled back to the state of how it is in git and creating tags works. Just the pre-filling tags-part doesnt work.. And I don't understand why. Maybe because details.tags get's set after a quick delay? (Because it has to read them from the database?)

dokar3 commented 7 months ago

It could be. If your tag list doesn't come from the initial state, you can update it when it's available.

var filledTags by remember { mutableStateOf(false) }

LaunchedEffect(chipState, details.tags, filledTags) {
    if (filledTags ||
        // Or depend on the loading state or something
        details.tags == null) return@LaunchedEffect
    chipState.chips = details.tags
    filledTags = true
}
SteffoSpieler commented 7 months ago

hm, this works - kinda.

I added an if, so it looks like this now:

if (appointmentDetails.tags.isNotEmpty()) {
            var filledTags by remember { mutableStateOf(false) }
            LaunchedEffect(chipState, appointmentDetails.tags, filledTags) {
                if (filledTags) return@LaunchedEffect
                chipState.chips = appointmentDetails.tags.map { Chip(it) }
                filledTags = true
            }
        }

But...

SteffoSpieler commented 7 months ago

It's not like I could just.. make appointmentDetails.tags a ChipTextFieldState, right? Last time I tried it android studio gave me errors. Because technically I'm already converting the list to a string on save.

But last time I tried it, I had an "this needs to be in a composable" error...

dokar3 commented 7 months ago

Emm...flickers, I didn't check your whole screen code, could it be the outer if caused this? Can you try to put it inside the LaunchEffect? If this launched effect runs exactly once, there should not be any side effects to cause flickers.

SteffoSpieler commented 7 months ago

it's now inside the LaunchEffect and the tags now don't get filled again.

What am I doing wrong here o.O

dokar3 commented 7 months ago

It's a little messy here...I will make a whole example later, it should work.

dokar3 commented 7 months ago

The whole example I have tested is here. If it still won't work, you may need to track the ui state (details object).

data class AppointmentDetails(
    val tags: List<String> = emptyList(),
)

class AppointmentViewModel : ViewModel() {
    private val _details = MutableStateFlow(AppointmentDetails())
    val details: StateFlow<AppointmentDetails> = _details

    init {
        viewModelScope.launch {
            delay(1000) // Load tags
            _details.update { it.copy(tags = listOf("Hello", "World")) }
        }
    }

    fun updateDetails(details: AppointmentDetails) {
        _details.update { details }
    }
}

@Composable
fun Fields(
    details: AppointmentDetails,
    onUpdateDetails: (AppointmentDetails) -> Unit,
    modifier: Modifier = Modifier,
) {
    val chipState = rememberChipTextFieldState<Chip>()

    var filledTags by remember { mutableStateOf(false) }

    LaunchedEffect(chipState, details.tags, filledTags) {
        if (filledTags || details.tags.isEmpty()) return@LaunchedEffect
        chipState.chips = details.tags.map(::Chip)
        filledTags = true
    }

    LaunchedEffect(chipState, details, onUpdateDetails) {
        snapshotFlow { chipState.chips.map { it.text } }
            .collect { onUpdateDetails(details.copy(tags = it)) }
    }

    OutlinedChipTextField(
        state = chipState,
        onSubmit = { Chip(it) },
        modifier = modifier,
    )
}

@Composable
private fun RootScreen(
    viewModel: AppointmentViewModel, // = viewModel(...)
    modifier: Modifier = Modifier,
) {
    val details by viewModel.details.collectAsState()
    Fields(
        details = details,
        onUpdateDetails = viewModel::updateDetails,
        modifier = modifier,
    )
}
SteffoSpieler commented 7 months ago

hm, this works - kinda.

I added an if, so it looks like this now:

if (appointmentDetails.tags.isNotEmpty()) {
            var filledTags by remember { mutableStateOf(false) }
            LaunchedEffect(chipState, appointmentDetails.tags, filledTags) {
                if (filledTags) return@LaunchedEffect
                chipState.chips = appointmentDetails.tags.map { Chip(it) }
                filledTags = true
            }
        }

But...

* The values in the other inputs flicker now a lot (when adding tags and when opening the edit page)

* The values in the other inputs get cleared when I'm adding a tag to an entry that doesn't have tags yet.

ok I might be stupid.

I took a short break. Okay, maybe a bit longer than short. And now I tested it on my phone, instead of the android emulator.

This works! Somehow..

Also the thing with the date was another issue that had nothing to do with this part.

The code, now:

        val initialChips = remember { appointmentDetails.tags.map(::Chip) }
        val chipState = rememberChipTextFieldState(chips = initialChips)
        if (appointmentDetails.tags.isNotEmpty()) {
            var filledTags by remember { mutableStateOf(false) }
            LaunchedEffect(chipState, appointmentDetails.tags, filledTags) {
                if (filledTags) return@LaunchedEffect
                chipState.chips = appointmentDetails.tags.map { Chip(it) }
                filledTags = true
            }
        }
        LaunchedEffect(onValueChange, chipState.chips) {
            snapshotFlow { chipState.chips.map { it.text } }
                .collect { appointmentDetails.tags = it }
        }
        OutlinedChipTextField(
            state = chipState,
            onSubmit = { Chip(it) },
            enabled = enabled,
            label = { Text(stringResource(R.string.form_field_tags_header)) },
        )

Thank you again for your time and for this awesome component! :D