· 6 min read Posted by Brady Aiello

Kotlin Multiplatform Example, KaMP Kit Goes Jetpack Compose (KoMPose Kit)

Kotlin Multiplatform example KaMP Kit now fully endorses Jetpack Compose in KMM. Brady writes, "Updating to Compose has simplified KaMP Kit."

KaMP Kit and Jetpack Compose

Hey folks, Brady from Touchlab here. I’ve only been at Touchlab since the beginning of the year, but KaMP Kit, our simple-but-not-too-simple sample project to help those considering Kotlin Multiplatform, started way back at the end of 2019. Back then, Jetpack Compose had just been announced in May. It was a time full of optimism about the modern mobile UI development experience, but also of wild instability. The first method of getting Jetpack Compose to work on your machine involved pulling down the AndroidX development toolchain, and running a special version of Android Studio via terminal commands. Eventually, preview, alpha, and beta version of Compose could be used in the canary version of Android Studio (more history). Starting July 28, 2021, Compose went stable, and a version of Android Studio Arctic Fox, which supports Compose, was released in the stable channel shortly thereafter. Now we can use a stable version of Compose with a stable version of Android Studio. We at Touchlab have been excited about Compose for a long time; you can watch us geek out about it here. And though the community has been interested in Compose for KaMP Kit since at least May 2020, we didn’t want folks who are trying out Kotlin Multiplatform with KaMP Kit to also have to learn a changing Jetpack Compose API, and require them to use a special version of Android Studio. Now that these obstacles have been removed, we feel comfortable fully endorsing Jetpack Compose in KMM.

Cutting Code

After moving to Jetpack Compose, we were able to remove a lot of things that clutter the KMP learning experience. XML views are gone, which means developers don’t need to worry about context switching between XML and Kotlin, findViewById(), or configure viewBinding or dataBinding. Removing the RecyclerView, its ViewHolder, and Adapter, and replacing it with a LazyColumn from Compose simplifies the sample considerably. Although we had to bring in Compose dependencies, @russhwolf noticed that by abandoning AppCompatActivity in favor of ComponentActivity, we were able to remove the large AppCompat library from our dependencies entirely.

Exposing a Flaw in our State Management

If you’re now converting an app to use Jetpack Compose, you may have noticed that modeling your view state using a sealed class may not work as well as it used to in the View world. That’s because Views implicitly kept state that we relied on. Compose made this more apparent, and forced us to stop relying on our UI for any state whatsoever.

For example, let’s say we have LoadingSuccess, and Error states to describe our UI, and that we are currently showing the Success state to describe a list of items in our UI, while fetching more data. In the View world, we emit a Loading state, which just makes the loading spinner visible, in addition to the stale list, while fetching a fresh list. It just comes down to showing what we’re already showing, and then making a loading spinner visible.

However, in the Compose world, we don’t have all possible views on the screen, only toggling some as visible. Instead, we need to emit all of the UI we want to show whenever the State changes. In our example, when we emit the Loading state, the success UI with our list of data goes away, and only the loading spinner is visible. This is very jarring, and not a great user experience. This is because we’re using a sealed class for something that’s not mutually exclusive. Success and Loading are not mutually exclusive, unless Loading only describes an empty screen with a loading spinner. Ryan Harter has written about this issue, and Android GDE @ditn Adam Bennett told me that his team at Cuvva also had this discussion. Perhaps the simplest solution is to have a data class with nullable fields:

data class DataState<out T>(
    val data: T? = null,
    val exception: String? = null,
    val empty: Boolean = false,
    val loading: Boolean = false
)

This covers the only Loading, only Success, only ErrorLoading and Success, and Loading and Error possibilities. It harkens back to the old Android architecture components samples’ Resource\<T\> class.

Though, some argue that those State combinations should all be mutually exclusive sealed classes, which is also a great approach that avoids the nullability issues. If Loading is the only State that can coexist with other States, we can also just add a boolean field.

Whichever way you go, you should make sure not to model any of your UI state as mutually exclusive of others unless it actually is mutually exclusive of others.

Swipe To Refresh

Swipe-to-refresh functionality is an extremely common UI element, and as such, it is available on Android’s legacy View system in SwipeRefreshLayout. The Compose equivalent isn’t part of the core Compose UI, but there is a solid solution.

To get this same functionality in Compose without implementing swipe-to-refresh yourself, you’ll want to use Accompanist-SwipeRefresh, which is a Google library, but isn’t officially part of Jetpack. You’ll also need to make sure that any content inside the SwipeRefresh Composable is scrollable. You may have to wrap some content in a Column with a verticalScroll modifier per the documentation. If you miss this step, you could emit a non-scrollable Error state, and be unable to swipe to refresh again.

Given its popularity, it seems a little strange that swipe-to-refresh isn’t a core part of Compose. But this brings us to another way that Compose shines. Compose, and its 1st party associated libraries, are completely unbundled from the operating system. This means that Compose can run on any device running Android API 21 (Lollipop) and newer.

Transforming Flows

Compose uses a special observable type to know when to update UI. In Compose, this is the State\<T\> class. When State changes, all @Composable functions dependent on that State are reinvoked, and emit the corresponding UI. By exposing data as StateFlows from our KMM module, we can use the Flow extension function collectAsState() and clean it up even more with delegate syntax. We want to collect the Flow safely, avoiding collection when the view goes to the background, and restarting it when it comes back to the foreground. We’ll use Manuel Vivo’s post, “A safer way to collect flows from Android UIs” as a guide to create a lifecycle-aware Flow.

val lifecycleOwner = LocalLifecycleOwner.current
val lifecycleAwareDogsFlow = remember(
        viewModel.breedStateFlow, 
        lifecycleOwner
    ) {
    viewModel.breedStateFlow
        .flowWithLifecycle(lifecycleOwner.lifecycle)
}
val dogsState by lifecycleAwareDogsFlow
    .collectAsState(viewModel.breedStateFlow.value)

The delegate syntax is nice because we get best of State and its backing data. Our dogsState is actually not a State, so we don’t need to put .value to get the value, but because it delegates its get()s to a State@Composable functions that take it as a parameter are still invoked whenever its value changes.

Conclusion

Jetpack Compose has been an exciting project to follow, and it’s clear that it has a bright future for reactive and declarative UI. Updating to Compose has simplified KaMP Kit, and exposed a flaw in our previous state management approach, forcing us to become better developers. Our goal with the KaMP Kit project is to give folks interested in Kotlin Multiplatform the easiest way to get started, and now that Compose is stable, it makes learning KMM easier than ever.