First Impressions of Jetpack Compose
A few months ago I built a web app which lets you upload a photo of your meal and it uses AI to identify it and give you nutritional info. For record keeping, you could use the Google Fit REST API to store the data long-term.
While the service worked fine, since then Google Fit announced its REST API will be shut down. Developers were asked to migrate to use Health Connect. Unfortunately for me, this meant it needed to be written as an Android app and not a web app.
I haven’t written Android apps in many years, probably since 2018 or so. As such, as I sat down with Android Studio I knew there were a lot of new design guidelines and programming paradigms. Even Health Connect as a platform feature is new.
After a few days of struggling and learning, I have made a lot of progress, as shown above.
Having AI code assist and Gemini embedded has been great. Especially when I already have Typescript written, that was helpful in autocompleting the code to Kotlin. As I haven’t used much Kotlin in my past, these short bits of assistance have been useful in guiding me to write the correct syntax.
What I also have spent time learning is Jetpack: an entirely new way of writing user interfaces. It’s designed to be entirely setup in code for greater flexibility and control while also being responsive.
As this is really my first time trying to do anything with Jetpack, I figured I’d write down some of the mistakes I made and some of the things I learned.
Layout
There are a few states I need for this app. First I need a Permission Request state, then an Upload Photo state, then a Processing Photo state, then finally a Nutrition Info state.
First I begin with calling setContent
on the default Material Scaffolding
, which provides a bunch of helpful subcomponents like a title bar and a floating action button.
setContent {
val showToast = remember { mutableStateOf(false) }
PhotoMealsTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
// Send user to Health Connect
TopAppBar(title = { Text("Photo Meals") },
actions = {
IconButton(onClick = {
// Send user to Health Connect
val intent = packageManager.getLaunchIntentForPackage("com.google.android.apps.healthdata")
if (intent !== null) {
startActivity(intent)
}
}) {
Icon(Icons.Filled.DateRange, contentDescription = "History")
}
IconButton(onClick = {
// Launch preferences activity
startActivity(Intent(this@MainActivity, SettingsActivity::class.java))
}) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
})
},
floatingActionButton = {
MyFab()
}) {
// Main app content...
}
You can see the TopAppBar
lets me add the app’s title and any actions. As everything is written in Kotlin, the button actions are written directly inline. This makes it easy to write and share. At the same time, I have been a follower of the model-view-controller pattern where you keep the UI and action separate. Looking at this all at once, it might be a little hard to follow.
As an aside, I was hoping that I could keep all the data in Health Connect without needing an additional database and just deeplink users into the app to see history. Health Connect has a package name that I can call, but you can’t go deep into specific days or data or any other activity.
There are ways to help organize this code a little more. Since this is all code, I can pull out individual parts to functions including entire elements. Functions marked as Composable can be pulled out, like MyFab
and place it separately.
@Composable
fun MyFab() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val showToast = remember { mutableStateOf(false) }
FloatingActionButton(
onClick = {
scope.launch {
// Perform your function here
insertHydration(healthConnectClient, parseDouble(preferences.getOrDefault(PREF_HYDRATION_ML, 300) as String))
showToast.value = true
}
} ) {
DrinkIcon()
}
if (showToast.value) {
Toast.makeText(context, "Added!", Toast.LENGTH_SHORT).show()
showToast.value = false // Reset the state
}
})
}
I’m still thinking how I feel about this code hierarchy. Should I be able to hide toasts all over my codebase? Will it become too hard to track random ones down? Is it too complicated to manage coroutine threads? I’m not sure yet. Since my app is largely in a single activity it’s fine, but I do wonder how it would be to maintain a larger one?
@Composable
private fun DrinkIcon() {
Icon(
painter = painterResource(id = R.drawable.baseline_local_drink_24),
contentDescription = "Drink",
tint = MaterialTheme.colorScheme.primary
)
}
It is nice to be able to create pre-defined Icon components that I can re-use throughout the app. There are definitely upsides. Hopefully as I continue learinng this, I can pick up on good rules of thumb.
Dynamism
The main content has several states and the UI needs to change depending on each one. The UI also includes different actions to change state, which is easy to bundle within each UI element.
Most of the app’s interface is made up of Column
and Row
components, nested amongst each other to define where things should be placed.
Column(
modifier = Modifier.padding(innerPadding),
) {
if (!hasAllPermission.value) {
Column {
Text("This app stores data securely into Health Connect.")
HCButton()
}
}
if (!isImageLoaded.value) {
Column(
modifier = Modifier.fillMaxHeight()
){
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Please select a photo to begin",
modifier = Modifier
.padding(top = 32.dp)
.padding(horizontal = 16.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp)
) {
// Button to let you select photo
Button(onClick = { dispatchSelectPictureIntent() }) {
CollectionIcon()
Text("Select Photo")
}
Button(
onClick = { dispatchTakePictureIntent() },
modifier = Modifier.padding(start = 16.dp)
) {
CameraIcon()
Text("Open Camera")
}
}
}
}
if (loadedBitmap.value != null) {
DisplayPhotoFromFile(loadedBitmap.value!!)
}
You can see that it’s pretty easy to load and unload different components and the UI refreshes automatically when things change. You can see that I only display the loadedBitmap
when there is a bitmap to display.
If the user hasn’t added one yet, then there are two buttons allowing them to select one. Using the modifier
parameter, I can string together different styles. I add some padding to the buttons and make sure everything feels well-spaced.
For this dynamism to work, I can’t use the values directly. loadedBitmap
can’t be a Bitmap
. Instead, I need to wrap it into a reactive MutableSharedFlow
.
val _loadedBitmap = MutableSharedFlow<ImageBitmap>()
val loadedBitmap = _loadedBitmap.asSharedFlow()
This needs to be done for every single state variable in my UI, if I want the UI to change when the variable changes. So in my full app I have 7 different pairs of variables defined as class variables. This feels like a lot. I have to wonder whether it would be better defining a single data class for all of my UI states.
Beyond defining these two, I also need to define additional variables that “collect” the value before rendering the updated UI.
val loadedBitmap = loadedBitmap.collectAsState(initial = null)
This isn’t a Bitmap
, but a bitmap wrapper. So to actually obtain the bitmap, I need to call loadedBitmap.value
. Again, this feels like a lot of code to write for each UI field and a single UI class might be better.
When I am ready to get a Bitmap
from the user, from their gallery or a new photo, I can use the class’s MutableSharedFlow
to emit
a change to the value of my bitmap and update the UI.
private suspend fun processBitmap(bitmap: Bitmap) {
lifecycleScope.launch {
_processState.emit("Querying AI with photo")
}
lifecycleScope.launch {
_isImageLoaded.emit(true)
_loadedBitmap.emit(bitmap.asImageBitmap())
}
// ...
}
This all works, and I’ve done this up with all my other UI states. It does feel messy to me, but that’s because I’ve put basically everything into a giant blob of UI and actions. I’d like to learn more about how a better organization would look if I spent time on a refactor.
More UI
When you take a photo, you get a list of different food items. I want to display these in a table, with each item to be properly aligned together. Some reading on StackOverflow convinced me this could just be done using Column
and Row
components with a fixed column size.
matchedFoods.value.forEachIndexed { index, it ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 16.dp)
) {
Text(it.foodSearchCriteria.query,
modifier = Modifier
.padding(end = 8.dp)
.weight(.4f))
TextField(
value = plates_.value[index].count.toString(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
onValueChange = {
if (it.isNotEmpty()) {
lifecycleScope.launch {
val copyPlates = plates_.value.toMutableList()
copyPlates[index].count = it.toInt() _plates.emit(emptyList())
_plates.emit(copyPlates)
}
}
}
},
label = { Text("Size") },
modifier = Modifier
.padding(end = 4.dp)
.weight(.25f)
)
Text(plates_.value[index].unit)
As you see, the Text
is given a weight of .4f
and it fits about 40% of the row. It looks fine on my phone, though I’m not sure if it’ll work well on tablets or other larger screens. I’d need to figure out how to change the weights relative to screen width.
When the AI scans my food it will give a serving size. But it may not be entirely correct and you’ll want to edit the quantity. To do this, I use a TextField
where the value is defined as the count
and whenever it is modified I emit
the change.
This does not work very well. There is some bug where you can’t get an empty string. If you try, it just appends the number at the end of the text field. It also feels messy using it. I think I should move that to a dialog to edit the text so that each row feels cleaner.
Next Steps
I think the Jetpack library is good at getting me started and putting together the app UI really quickly. The reactiveness makes it easy to manage app state without a ton of custom UI-specific code.
There are definitely a few things I still need to figure out before my app is ready for launch. But I did manage to port most of my web UI to app UI already. The Health Connect functions were similarly easy to use.
So look forward soon to getting this app onto your phone soon.