Thread
#ballast
    lazt omen

    lazt omen

    2 months ago
    Hello I’m looking for some ideas here. I’m trying to model a system to pass dialogs throughout my application. Currently all my components receive state and postInput parameters. the problem is calling ShowDialog passing one of my dialogs as an input looses the ability to observe value changes in state since this state is no longer the same as the one in the composable tree. I thought about passing the view model instead of the state, but that feels like breaking the model. Can anyone help me here?
    object RouterContract {
        data class State(
            val currentRoute: PageRoute = PageRoute.SETTINGS,
            val routeMetadata: Map<String, Any> = mapOf(),
            val showWindow: Boolean = true,
            val activeDialog: (@Composable () -> Unit)? = null
        )
    
        sealed class Inputs {
            data class NavigateTo(
                val newRoute: PageRoute,
                val metadata: Map<String, Any> = mapOf()
            ) : Inputs()
    
            object CloseApp : Inputs()
            data class ShowDialog(val newDialog: @Composable () -> Unit) : Inputs()
            object CloseDialog: Inputs()
        }
    
        sealed class Events
    }
    This is how I call the dialogs
    if (routerState.activeDialog != null) {
        Surface(
            color = Color.White.copy(alpha = 0.6F),
            modifier = Modifier.fillMaxSize()
                .clickable(MutableInteractionSource(), indication = null) {
                    routerVm.trySend(RouterContract.Inputs.CloseDialog)
                }
        ) {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                routerState.activeDialog?.invoke()
            }
        }
    }
    c

    Casey Brooks

    2 months ago
    In general, it’s not wrong to pass a ViewModel around your application, especially for ones that are naturally app-wide concerns like Routing or Repositories. The main reason for preferring to pass the
    State
    and
    postInput
    is more for the screen-level ViewModels, in following with the principles of Compose of lifting screen state, and helps avoid tight coupling to the ViewModel itself. So it’s preferable to lift the Ballast state, but not strictly necessary. Also, the problem here isn’t really a Ballast problem, but more general Compose. I’ve run into difficulties passing Composable lambdas up the tree because it kinda breaks the mental model and makes it difficult to update properly. You’ll basically need to observe the VM state directly within that lambda, so passing the VM itself will be necessary to update the dialog contents as you’d expect. You’ll need to think of that lambda as a new content root with nothing in it, because thats where it’s actually being placed in the composition (the root of your application with the router), despite the fact that the lambda is created further down the tree, which is really unintuitive.
    So in general, I would suggest not having a lambda in your
    RouterContract.State
    , but maybe take an approach more similar to the route itself and just place some “token” representing the active dialog in the router. Then your screens can observe the Router state in some way, check if the token is one they care about, and render the dialog deeper within the composition rather than at the root. There are a couple ways you could implement this system, depending on how complex you want to get, where you want to post dialogs from, and how tightly you want to couple yourself to the Router. 1) Just pass the Router state/postInput into your screens (or the VM itself), along with the screen’s VM state. Could potentially use a CompositionLocal or DI to hold onto the Router ViewModel to keep things tidier. This would be the simplest method, but couples your screens pretty tightly to the Router. 2) Treat the Router as a dependency of each screen’s own ViewModels. Expose the methods for showing/hiding dialogs on the individual screen contracts, and it’s only on the implementation of the InputHandler that you’re actually interacting with the Router. This can help you build your screens decoupled from the router itself and clarify which dialogs belong to which screens, at the cost of some additional boilerplate
    Here’s an example of #2:
    object SettingsDialog : DialogRoute()
    
    object SettingsContract {
        data class State(
            val showDialog: Boolean = false,
        )
    
        sealed class Inputs {
            object Initialize : Inputs()
            data class RouterStateUpdated(val routerState: RouterContract.State) : Inputs()
            data class ShowDialog(val newDialog: DialogRoute) : Inputs()
            object CloseDialog : Inputs()
        }
    
        sealed class Events
    }
    
    class SettingsInputHandler(
        private val router: RouterViewModel
    ) : InputHandler<
        SettingsContract.Inputs,
        SettingsContract.Events,
        SettingsContract.State> {
        override suspend fun InputHandlerScope<
            SettingsContract.Inputs,
            SettingsContract.Events,
            SettingsContract.State>.handleInput(
            input: SettingsContract.Inputs
        ) = when (input) {
            is SettingsContract.Inputs.Initialize -> {
                observeFlows(
                    "router",
                    router.observeStates().map { SettingsContract.Inputs.RouterStateUpdated(it) }
                )
            }
            is SettingsContract.Inputs.RouterStateUpdated -> {
                updateState { it.copy(showDialog = input.routerState.activeDialog is SettingsDialog) }
            }
            is SettingsContract.Inputs.ShowDialog -> {
                router.trySend(RouterContract.Inputs.ShowDialog(SettingsDialog))
                noOp()
            }
            is SettingsContract.Inputs.CloseDialog -> {
                router.trySend(RouterContract.Inputs.CloseDialog)
                noOp()
            }
        }
    }