Building a collapsible Extended Floating Action Button with Jetpack Compose
In this post, we are building the collapsible Extended Floating Action Button animation specified in the Material Design 3 guidelines.
When the user scrolls up to reveal more content, the Extended FAB collapses into a regular FAB. This effect is easy to implement once we find the suitable APIs with Jetpack Compose, namely:
AnimatedVisibility
takes care of the animationsnapshotFlow
lets us listen to scroll offset given byScrollState.value
Create our FAB component
Out of the box, the androidx.compose.material3
library gives us an ExtendedFloatingActionButton
component.
Unfortunately, it takes an optional icon
composable, while what we want to achieve has a persistent icon across the two states. So, we’ll have to roll our own:
@Composable
fun ExtendableFloatingActionButton(
modifier: Modifier = Modifier,
extended: Boolean,
text: @Composable () -> Unit,
icon: @Composable () -> Unit,
onClick: () -> Unit = {}
) {
FloatingActionButton(
modifier = modifier,
onClick = onClick,
) {
Row(
modifier = Modifier.padding(
start = PaddingSize,
end = PaddingSize
),
verticalAlignment = Alignment.CenterVertically
) {
icon()
AnimatedVisibility(visible = extended) {
Row {
Spacer(Modifier.width(12.dp))
text()
}
}
}
}
}
private val PaddingSize = 16.dp
The composable function takes an extended: Boolean
parameter to indicate whether the optional text label is shown. This state is hoisted up to the parent composable, so that we can change it according to the scroll state of the background content.
Inside the component, we wrap the optional text label inside an AnimatedVisibility
block. The AnimatedVisibility
is like a drop-in replacement for an if
conditional block. When the extended
state changes, Compose will interpolate between the two states and animate the appearance/disappearance of content automatically.
Listen to scroll events
Compose offers two sets of modifiers for handling scroll gestures:
Modifier.verticalScroll
(andhorizontalScroll
), which take aScrollState
;Modifier.scrollable
, which takes aScrollableState
.
By passing in either a ScrollState
or ScrollableState
object to these modifiers, we can read off scroll states and manually control how the content is scrolled.
Notice ScrollState
with a shorter name is the more commonly used API.
To subscribe to scroll events, I expected to find something to which we can attach a callback (like RecyclerView.OnScrollListener
),
or an event stream like a Flow
of scrolled offsets.
It turns out ScrollableState
does take a lambda. We can grab the current scroll position in this lambda and store it
in a MutableState
.
@Composable
fun ScrollableSample() {
// actual composable state
var offset by remember { mutableStateOf(0f) }
Box(
Modifier
.size(150.dp)
.scrollable(
orientation = Orientation.Vertical,
// Scrollable state: describes how to consume
// scrolling delta and update offset
// highlight-range{1-4}
state = rememberScrollableState { delta ->
offset += delta
delta
}
)
.background(Color.LightGray),
contentAlignment = Alignment.Center
) {
Text(offset.toString())
}
}
However, Modifier.scrollable
is a low-level primitive used internally by Modifier.verticalScroll
.
It detects the scroll gestures, but the content itself won’t be scrolled by default.
In this way, we’ll have to duplicate a lot of logic in Modifier.verticalScroll
.
On the other hand, ScrollState
used by Modifier.verticalScroll
gives us the current scroll position through the ScrollState.value
attribute directly.
// 👇 ScrollState exposes an integer scroll position value.
@Stable
class ScrollState(initial: Int) : ScrollableState {
/**
* current scroll position value in pixels
*/
var value: Int by mutableStateOf(initial, structuralEqualityPolicy())
private set
// ...
}
It isn’t obvious, but we can subscribe to this integer value by using snapshotFlow
.
This is how we can achieve the collapsible FAB behavior:
val verticalScroll = rememberScrollState()
var fabExtended by remember { mutableStateOf(true) }
LaunchedEffect(verticalScroll) {
var prev = 0
snapshotFlow { verticalScroll.value }
.collect {
fabExtended = it <= prev
prev = it
}
}
Scaffold(
floatingActionButton = {
ExtendableFloatingActionButton(extended = fabExtended, /**/)
}
) {
Column(Modifier.verticalScroll(verticalScroll) { /**/ }
}
How does snapshotFlow
work?
At first blush, the snapshotFlow
looks a bit magical to me. Its lambda parameter only captures a primitive value, but somehow, it can react to MutableState
changes and emit new values.
Where does this reactivity come from? Under the hood, snapshotFlow
uses the Snapshot API of the Compose runtime. Here is a simple demonstration:
class Counter(
val count: MutableState<Int> = mutableStateOf(0)
)
suspend fun snapshotFlow() {
val counter = Counter()
coroutineScope {
val job = launch {
snapshotFlow { counter.count.value }
.collect { println(it) }
}
launch {
repeat(5) {
delay(1000)
counter.count.value = counter.count.value + 1
Snapshot.sendApplyNotifications()
}
job.cancel()
}
}
}
// Prints out 0, 1, 2, 3, 4, 5 with 1 second interval.
On a high level, Snapshot keeps track of all MutableState
declared in the application.
Using this API, snapshotFlow
records which states are read inside the lambda.
Whenever those states are changed, the lambda passed into snapshotFlow
is rerun and the resulting flow emits a new value.
The little remaining space in this blog post doesn’t do justice to this interesting part of the Compose runtime. I would like to refer my readers to Zach Klippenstein’s excellent post Introduction to the Compose Snapshot system.