One Off the Slack

These articles summarize the discussion on a particular thread in the #compose channel of JetBrains’ Slack workspace for Kotlin developers. If you would like to read the original thread, you will need access to that workspace — sign up at https://slack.kotlinlang.org!

State, Events, and Navigation

Tiago Nunes asked:

Hey everyone, how do I send a one-time event from the viewmodel to the composable? I want to navigate after a long operation

Part of the challenge is being “in the wrong frame” in the question.

In every UI system that I have ever seen, in the end, “what screen am I on?” is part of state, and events that change what screen that is showing creates a change in state. The problem is that too many UI systems hide that state and offer an API that is rather event-centric. This includes Android’s activity-and-fragments system, where the API is expressed in terms of events (“start an activity”, “replace a fragment”, “navigate to a destination”). So, we wind up thinking in terms of events affecting some “black box”, and we wind up tying ourselves in knots trying to figure out how to ensure that we are idempotent and that our event winds up changing our state correctly.

As the thread evolved, Tiago expressed concern about composables knowing stuff about navigation. Nelson Glauber countered:

My navigation logic is not in my composable 🙂 I keep it in the NavGraph. I just pass lambdas/callbacks to my composables. 😉

Google’s Manuel Vivo then chimed in:

Hi 👋 What are you using for navigation? Are you using the Navigation component? What about passing the navController as a dependency to the ViewModel so that the VM can handle the event instead of sending it to the composable?

…you can make the ViewModel be the source of truth for the navController instead of a composable. There’s nothing wrong with that

We’re coming up with guidance for events in Android. But it’s taking longer than expected. So far, the rules of thumb we have are:

  1. Let the ViewModel handle the event whenever it’s possible, and
  2. If the event is critical for the screen, model it as state

With Compose, #1 is possible because the Navigation component hoists its state via navController. This is something that’s not possible with the View system where the navigation state is a black box inside the NavHost itself

If we’re talking about Compose specifically, I think it’s safe to say that the state holder (that could be a ViewModel) should process the events and make state changes to reflect that on the UI.

i.e. do you need to show a snackbar? The state holder can take be the [source of truth] for the snackbarHostState, and call showSnackbar when needed. The same pattern applies for navController.navigate calls.

Colton Idle asked:

Do you think any of the compose sample apps do a good job at showing how navigation should be done in a pure compose app? I’m still hung up on how an app with a login screen would work + de authenciation would kick them out of the app.

Manuel replied:

You have to bear with us 🙂 this is also very new to us and we’re experimenting. In fact, I think there are only a couple of samples that use Compose Navigation. So I agree we need to cover more ground here

Colton then fretted a bit:

Passing navController to VM? I wonder if that keeps the AAC VM testable. Makes sense at a high level to me. But boy oh boy. Do we need more docs around this IMO.

Manuel was less worried:

Yeah, the VM is also testable because it takes the other hoisted states (e.g. navController and snackbarHostState) as dependencies so you can assert interactions with them and even fake them if needed

Part of the concern here is that Navigation for Compose is an Android-specific library, and not everyone is targeting Android alone, such as Tiago:

We’re also using KMM, but we’re not sharing the VM (yet). If we share the VM at some point, we might need to find another solution (because there might not be a way to supply the VM with a “navController” in the iOS side). In this use case, having a “dumb” VM sounds best. Of course this is out of scope, but still something to consider 🙂

Manuel agreed:

Ideally in iOS, you should also be able to hoist the navigation state. If that’s the case, you could hide the differences between the Android and iOS implementations under an interface, and let your state holder take that instead

I’m purposely using state holder instead of VMs because the latter is an implementation detail IMO

Posted 2021-07-24, based on https://kotlinlang.slack.com/archives/CJLTWPH7S/p1626697968003700


@Preview and a ViewModel, Again

Jeffrey Nyauke asked:

Noticed that previews do not work when I use a viewModel in a composable. How do you get around this?

This sounds like the topic of a previous “One Off the Slack” post. And, as that one is only three months old, the advice from there is sound, and some of it is linked to in this new Slack thread.

However, we also got some new advice from different advisers.

Bryan Herbst offered:

I’m generally creating two composables- one that uses the view model (either as a parameter or via the viewModels() extension function) and one that only takes in the state I need. E.g.:

@Composable
fun AccountScreen(
  viewModel: AccountViewModel
) {
  AccountScreen(viewModel.screenState)
}

@Composable
private fun AccountScreen(
  state: AccountScreenState
) {
  // ...
}

// Now you can actually write a preview
@Preview
@Composable
private fun LoggedInAccountPreview() {
  AccountScreen(AccountScreenState.LoggedIn)
}

This works nicely, in that the previewable content is in a dedicated function, with the ViewModel being used in a wrapper.

Jeffrey then asked:

What if the composable takes in a viewmodel to make a call like viewModel.login()

Brandon McAnsh replied:

hoist that up back to the parent composable via a lambda

actioner: (Event) -> Unit

The gist of both Bryan and Brandon’s responses is that the previewable composables should be dealing with basic types, core Compose constructs (e.g., State), and lambdas… and little else.

Jeffrey persisted:

So practically there is no way to preview a screen with many composables if I’m using the viewModel?

At this point, Google’s Ian Lake pointed to a previous discussion, illustrating yet another approach: have the @Preview function accept a ViewModel as a parameter, but provide a default value for that ViewModel, such as one that you get from your DI framework (Koin, Dagger/Hilt, or whatever).

Jeffrey pointed out that this may not be as easy as described:

When I pass it in as a parameter, and include a default value for the ViewModel that pulls from Koin, the composable does not display. I get the message Koin cannot be initialized.

Ian responded:

The whole point of passing it in is so that your preview code can override the default value and provide a proper test/fake for your preview to use

Discussions then continued on how to actually provide a fake… but at that point, I really start to question the sanity of the approach. Going back to the earlier “One Off the Slack” post, I’ll recommend this comment from Google’s Jim Sproch:

When your composable does not behave well in Preview, that is almost always an indication your composable is not sufficiently isolated from the rest of the platform/application code.

…and this one from Google’s Sean McQuillan:

I’d extend this to say any reference to a stateful final class in a composable is something to add carefully as it makes your composable inherently stateful, which can make testing and preview harder.

Posted 2021-07-17, based on https://kotlinlang.slack.com/archives/CJLTWPH7S/p1626168536066700


Filling the Viewport

Ilia Voitcekhovskii asked:

I have Column > Box > Column hierarchy. All have fillMaxSize modifier, while box also has verticalScroll. As soon as box has scroll, its child column can no longer be at max size. Is this expected?

@Composable
fun TestComposable() {
    val scrollState = rememberScrollState()
    Column(
        Modifier
            .fillMaxSize()
            .background(Color(0xFFA05151))
    ) {
        Box(
            Modifier
                .fillMaxSize()
                .verticalScroll(scrollState)
                .background(Color(0xFF1178AF))
        ) {
            Column(
                Modifier
                    .fillMaxSize()
                    .background(Color(0xFF48AF11))
            ) {
                Text(text = "hello", color = Color(0xFFFFFFFF))
            }
        }
    }
}

Google’s Adam Powell said, in short, “yes”:

This is expected. The content of a scrolling region has infinite max size, and it is not possible to fill an infinite max size.

Illa was wondering how to achieve the same basic logic as android:fillViewport in the classic View system.

Adam offered:

var constraints by remember { mutableStateOf(Constraints()) }
    Column(
        Modifier
            .fillMaxSize()
            .onMeasureConstraints { constraints = it }
            .verticalScroll(rememberScrollState())
    ) {
        Box(
            Modifier
                .fillMaxWidth()
                .constrainSize { constraints }
                .background(Color.Blue)
        )
        Box(Modifier.fillMaxWidth().height(100.dp).background(Color.Green))
    }
// elsewhere at the top level...
fun Modifier.onMeasureConstraints(
    block: (Constraints) -> Unit
) = layout { measurable, constraints ->
    // record the constraints *before* measuring so that they're available during recursive measurement
    block(constraints)
    val placeable = measurable.measure(constraints)
    layout(placeable.width, placeable.height) {
        placeable.place(0, 0)
    }
}
fun Modifier.constrainSize(
    getConstraints: () -> Constraints
) = layout { measurable, constraints ->
    val placeable = measurable.measure(constraints.constrain(getConstraints()))
    layout(placeable.width, placeable.height) {
        placeable.place(0, 0)
    }
}

Adam elaborated:

then the two modifiers there and the general technique are reusable wherever else you might want to apply it

you could do things like fillMaxHeight(0.9) to make sure that there’s always a bit of scrollable content still visible as opposed to filling the entire viewport, etc.

it’s specifically important that constrainSize defines getConstraints as a function and not a raw value, since invoking that function performs a state read of the previously written constraints, invalidating the measurement of the constrainSize‘d element when the input constraints change, but without invalidating the composition that created it

Illa had questions about that last bit:

I assume that everything that reads from state container will be invalidated when value inside it changes, right? So the difference here is that if i read raw value - then whole scope that uses constraints state needs to be invalidated (which in this example starts with Column) since there no other way to trigger change, but if there is a function inside layout that reads it, then that invalidation scope is just that layout block - so only Box and its children will get invalidated?

Adam responded:

Yes. By using a lambda given to the layout modifier to read the state, you invalidate the layout that calls that lambda function rather than the composition

Posted 2021-07-11, based on https://kotlinlang.slack.com/archives/CJLTWPH7S/p1625934686417600


More!

Older “One Off the Slack” articles can be found in the archives.