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.
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
dataBinding. Removing the
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.
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
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.
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
Error possibilities. It harkens back to the old Android architecture components samples'
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 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.
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
val lifecycleOwner = LocalLifecycleOwner.current
val lifecycleAwareDogsFlow = remember(
val dogsState by lifecycleAwareDogsFlow
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
@Composable functions that take it as a parameter are still invoked whenever its value changes.
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.