
Learning to speak Compose
Jetpack Compose is a paradigm shift unlike any other in the history of Android development. There have been several significant changes in Android development: the introduction of Fragments, the addition of Jetpack features like ViewModels and LiveData, the adoption of Kotlin and coroutines. Still none of these changes has offered so much reward or required such a mental shift as Jetpack Compose.
I recently saw an Android developer tweet about the irony that Senior Android Engineers are currently performing Google searches like “how to display text on the screen.” He’s not wrong. As we are all re-orienting to a Compose world, I want to offer up a learning experience I had recently as an opportunity for others to learn as well.
As a side project to learn Compose, I was developing an app that displays multi-section text documents. So at a high level something like this:
@Composable
fun Document() {
LazyColumn {
items(sections) {
Section(it)
}
}
}
Some of these documents could be pretty long, so I wanted to add some sort of quick navigation within a document. It occurred to me that a Slider at the bottom of the screen could be a cool way of doing it (and an excuse to use a Slider in Compose!). Little did I know that I had a Compose Light Bulb moment waiting just around the corner…
A basic Slider in Compose looks like this:
var sliderPosition by remember { mutableStateOf(0f) }Slider(
value = sliderPosition,
onValueChange {
sliderPosition = it
}
)
Compose’s LazyColumn
provides a handy state: LazyListState
parameter for doing things like scrolling to a specific item. So my first thought was to call this in the Slider’sonValueChange
:
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()... LazyColumn here ...var sliderPosition by remember { mutableStateOf(0f) }Slider(
value = sliderPosition,
onValueChange {
sliderPosition = it
coroutineScope.launch {
listState.scrollToItem(it.roundToInt())
}
}
)
This works great, but it only works one way. When I drag the Slider, the document scrolls as expected (take a moment to appreciate how simple that was, compared to RecyclerViews, Adapters, LayoutManagers, and XML layouts!). My dilemma was that when I scroll the document itself, the Slider doesn’t update. Somehow I needed to get sliderPosition
to reflect listState.firstVisibleItemIndex
. My initial thought was simple:
var sliderPosition by remember { mutableStateOf(
listState.firstVisibleItemIndex
) }
But as you can guess, this only updates the initial value but doesn’t actually capture the state. So how do I capture the state of the LazyColumn’s scroll position? Some of you may have seen my mistake already. But for those of you who haven’t, prepare for a Light Bulb moment in thinking in Compose.
Jetpack Compose is “a modern declarative UI Toolkit for Android.” I’ve had a hart time wrapping my mind around what exactly “declarative” means. as compared to the older “imperative” way of doing UI. Here’s one way of thinking about it. In language, an imperative sentence is a command: “Add x and y.” So if x is 5 and y is 7, then “Add x + y” is equivalent to “Add 5 and 7” and the answer is 12. If we “run this command” again, the answer is still 12. A declarative sentence, on the other hand, simply describes the way things are. For example,“You add x and y.” In this scenario, we are simply describing the way things are. What do you do? You add x and y. So if x and y are 5 and 7, you get 12. But if x and y change to 1 and 2 and we “run the command” again, now you get 3. In a declarative world, we are simply describing the way things should be in relation to state, and so the output changes with the state.
So back to the Slider dilemma, what I was neglecting to realize was that there isn’t anything magic about the line:
var sliderPosition by remember { mutableStateOf(0f) }
that made it suitable to be assigned as the Slider’s value in
value = sliderPosition
or to be changed in
onValueChange {
sliderPosition = it
}
In imperative programming, value = sliderPosition
is an assignment (set value equal to sliderPosition). But in a declarative world like Compose, this command is describing the way things should be. So, “whenever this composable function is called, set the Slider’s value equal to sliderPosition.” If we made sliderPosition static (i.e. sliderPosition = 3
) then it would never change. But since we used a remember {}
to create it, the value changes with the state because Compose does a recomposition whenever the state changes.
Let me show you the final solution and describe what’s going on. Here it is:
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()... LazyColumn here ...Slider(
value = listState.firstVisibleItemIndex.toFloat(),
onValueChange {
coroutineScope.launch {
listState.scrollToItem(it.roundToInt())
}
}
)
No remember {}
at all. Why? Because listState.firstVisibleItemIndex
is a value read on state from LazyListState
. So whenever the state changes, Compose does a recomposition (it re-runs our Composable function). And when it re-runs it, what value gets assigned to the Slider?
listState.firstVisibleItemIndex
This was a light bulb moment for me in understanding how Compose works and what it means for Compose to be “declarative.” Hopefully you’ve had your own light bulb moment with Compose. I’m sure we will all have many more as we continue learning to speak Compose.