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 View
s 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 Loading
, Success
, 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 Error
, Loading
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 class
es, 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 StateFlow
s 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.
Top comments (0)