Extensions for Jetpack/JetBrains Compose¶
Extensions and utilities for easier integration of Decompose with Jetpack/JetBrains Compose.
Setup¶
Please see the corresponding Installation docs section.
ProGuard rules for Compose for Desktop (JVM)¶
If you support Compose for Desktop, you will need to add the following rule for ProGuard, so that the app works correctly in release mode. See Minification & obfuscation section in Compose docs for more information.
-keep class com.arkivanov.decompose.extensions.compose.jetbrains.mainthread.SwingMainThreadChecker
Converting Value to State¶
To convert Decompose Value to Compose State
use Value<T>.subscribeAsState(): State<T>
extension function:
interface SomeComponent {
val model: Value<Model>
data class Model(/*...*/)
}
@Composable
fun SomeContent(component: SomeComponent) {
val model: State<Model> by component.model.subscribeAsState()
}
Controlling the Lifecycle on Desktop¶
When using JetBrains Compose, you can have a LifecycleRegistry
react to changes in the window state using the LifecycleController()
composable. This will trigger appropriate lifecycle events when the window is minimized, restored or closed.
It is also possible to manually start the lifecycle using LifecycleRegistry.resume()
when the instance is created.
fun main() {
val lifecycle = LifecycleRegistry()
val root = RootComponent(DefaultComponentContext(lifecycle))
// Alternative: manually start the lifecycle (no reaction to window state)
// lifecycle.resume()
application {
val windowState = rememberWindowState()
// Bind the registry to the life cycle of the window
LifecycleController(lifecycle, windowState)
Window(state = windowState, ...) {
// The rest of your content
}
}
}
Warning
When using Compose in desktop platforms, make sure to always use one of the methods above, or your components might not receive lifecycle events correctly.
Navigating between Composable components¶
The Child Stack navigation model provides ChildStack as Value<ChildStack>
that can be observed in a Composable
component. This makes it possible to switch child Composable
components following the ChildStack
changes.
Both Compose extension modules provide the Children(...) function which has the following features:
- It listens for the
ChildStack
changes and displays the corresponding childComposable
component using the provided slot lambda. - It preserves components' UI state (e.g. scrolling position) in the back stack and over configuration changes and process death.
- It animates between children if there is an
animation
spec provided.
Here is an example of switching child components on navigation:
// Root
interface RootComponent {
val childStack: Value<ChildStack<*, Child>>
sealed class Child {
data class MainChild(val component: MainComponent) : Child()
data class DetailsChild(val component: DetailsComponent) : Child()
}
}
@Composable
fun RootContent(rootComponent: RootComponent) {
Children(rootComponent.childStack) {
when (val child = it.instance) {
is MainChild -> MainContent(child.component)
is DetailsChild -> DetailsContent(child.component)
}
}
}
// Children
interface MainComponent
interface DetailsComponent
@Composable
fun MainContent(component: MainComponent) {
// Omitted code
}
@Composable
fun DetailsContent(component: DetailsComponent) {
// Omitted code
}
Child Slot navigation with Compose¶
Child Slot navigation model can be used for different purposes. It can be used to just show/hide a certain part of UI, or to present a dialog, or a sheet (like Material Bottom Sheet). Although Decompose doesn't provide any special Compose API for Child Slot, it's pretty easy to do it manually.
interface RootComponent {
val dialog: Value<ChildSlot<*, DialogComponent>>
}
@Composable
fun RootContent(component: RootComponent) {
val dialogSlot by component.dialog.subscribeAsState()
dialogSlot.child?.instance?.also {
DialogContent(component = it)
}
}
interface DialogComponent {
fun onDismissClicked()
}
@Composable
fun DialogContent(component: DialogComponent) {
AlertDialog(
onDismissRequest = component::onDismissClicked,
title = { Text(text = "Title") },
text = { Text(text = "Message") },
confirmButton = {
TextButton(onClick = component::onDismissClicked) {
Text("Dismiss")
}
},
modifier = Modifier.width(300.dp),
)
}
Note
Child Slot might not be suitable for a Navigation Drawer. This is because the Navigation Drawer can be opened by a drag gesture at any time. The corresponding component should be always created so that it's always ready to be rendered.
Pager-like navigation¶
Warning
This navigation model is experimental, the API is subject to change.
The Child Pages navigation model provides ChildPages as Value<ChildPages>
that can be observed in a Composable
component.
Both Compose extension modules provide the Pages(...) function which has the following features:
- It listens for the
ChildPages
changes and displays child components usingHorizontalPager
orVerticalPager
(see the related Jetpack Compose documentation). - It animates page changes if there is an
animation
spec provided.
@Composable
fun PagesContent(component: PagesComponent) {
Pages(
pages = component.pages,
onPageSelected = component::selectPage,
scrollAnimation = PagesScrollAnimation.Default,
) { _, page ->
PageContent(page)
}
}
@Composable
fun PageContent(component: PageComponent) {
// Omitted code
}
Animations¶
Decompose provides Child Animation API for Compose, as well as some predefined animation specs. To enable child animations you need to pass the animation
argument to the Children
function. There are predefined animators provided by Decompose.
Fade animation¶
@Composable
fun RootContent(component: RootComponent) {
Children(
stack = component.childStack,
animation = stackAnimation(fade()),
) {
// Omitted code
}
}
Slide animation¶
@Composable
fun RootContent(component: RootComponent) {
Children(
stack = component.childStack,
animation = stackAnimation(slide()),
) {
// Omitted code
}
}
Combining animators¶
It is also possible to combine animators using the plus
operator. Please note that the order matters - the right animator is applied after the left animator.
@Composable
fun RootContent(component: RootComponent) {
Children(
stack = component.childStack,
animation = stackAnimation(fade() + scale())
) {
// Omitted code
}
}
Separate animations for children¶
Previous examples demonstrate simple cases, when all children have the same animation. But it is also possible to specify separate animations for children.
@Composable
fun RootContent(component: RootComponent) {
Children(
stack = component.childStack,
animation = stackAnimation { child ->
when (child.instance) {
is MainChild -> fade() + scale()
is DetailsChild -> fade() + slide()
}
}
) {
// Omitted code
}
}
It is also possible to take into account the other child and the animation direction when selecting the animation.
@Composable
fun RootContent(component: RootComponent) {
Children(
stack = component.childStack,
animation = stackAnimation { child, otherChild, direction ->
// Select and return an animator here
}
) {
// Omitted code
}
}
Custom animations¶
It is also possible to define custom animations.
Implementing StackAnimation
¶
This is the most flexible low-level API. The animation block receives the current ChildStack
and animates children using the provided content
slot.
@Composable
fun RootContent(component: RootComponent) {
Children(
stack = rootComponent.childStack,
animation = someAnimation(),
) {
// Omitted code
}
}
fun <C : Any, T : Any> someAnimation(): StackAnimation<C, T> =
StackAnimation { stack: ChildStack<C, T>,
modifier: Modifier,
content: @Composable (Child.Created<C, T>) -> Unit ->
// Render each frame here
}
Implementing StackAnimator
¶
The stackAnimation
function takes care of tracking the ChildStack
changes. StackAnimator
is only responsible for manipulating the Modifier
in the given direction
, and calling onFinished
at the end.
@Composable
fun RootContent(component: RootComponent) {
Children(
stack = component.childStack,
animation = stackAnimation(someAnimator()),
) {
// Omitted code
}
}
fun someAnimator(): StackAnimator =
StackAnimator { direction: Direction,
isInitial: Boolean,
onFinished: () -> Unit,
content: @Composable (Modifier) -> Unit ->
// Manipulate the Modifier in the given direction and call onFinished at the end
}
Using stackAnimator
function¶
This is the simplest, but less powerful way. The stackAnimator
function takes care of running the animation. Its block has a very limited responsibility - to render the current frame using the provided factor
and direction
.
@Composable
fun RootContent(component: RootComponent) {
Children(
stack = component.childStack,
animation = stackAnimation(someAnimator()),
) {
// Omitted code
}
}
fun someAnimator(): StackAnimator =
stackAnimator { factor: Float,
direction: Direction,
content: (Modifier) -> Unit ->
// Render the current frame
}
Please refer to the predefined animators (fade
, slide
, etc.) for implementation examples.
Predictive Back Gesture¶
Warning
Predictive Back Gesture support is experimental, the API is subject to change. For now, please use version 2.1.x.
Child Stack
supports the new Android Predictive Back Gesture on all platforms. By default, the gesture animation resembles the predictive back design for Android, but it's customizable.
To enable the gesture, first implement BackHandlerOwner
interface in your component with Child Stack
, then just pass predictiveBackAnimation
to the Children
function.
interface RootComponent : BackHandlerOwner {
val stack: Value<ChildStack<...>>
fun onBackClicked()
}
class DefaultRootComponent(
componentContext: ComponentContext,
) : ComponentContext by componentContext, BackHandlerOwner {
// ComponentContext already implements BackHandlerOwner, no need to implement it separately
// Omitted body
override fun onBackClicked() {
navigation.pop()
}
}
@Composable
fun RootContent(component: RootComponent) {
Children(
stack = rootComponent.childStack,
animation = predictiveBackAnimation(
backHandler = component.backHandler,
animation = stackAnimation(fade() + scale()), // Your usual animation here
onBack = component::onBackClicked,
),
) {
// Omitted code
}
}
Predictive Back Gesture on Android¶
On Android, the predictive back gesture only works starting with Android T. On Android T, it works only between Activities, if enabled in the system settings. Starting with Android U, the predictive back gesture can be enabled between Child Stack
screens inside a single Activity.
Predictive Back Gesture on other platforms¶
On all other platforms, the predictive back gesture can be enabled by showing a special overlay that automatically handles the gesture and manipulates BackDispatcher
as needed.
val lifecycle = LifecycleRegistry()
val backDispatcher = BackDispatcher()
val componentContext =
DefaultComponentContext(
lifecycle = lifecycle,
backHandler = backDispatcher, // Pass BackDispatcher here
)
val root = DefaultRootComponent(componentContext = componentContext)
PredictiveBackGestureOverlay(
backDispatcher = backDispatcher, // Use the same BackDispatcher as above
backIcon = { progress, _ ->
PredictiveBackGestureIcon(
imageVector = Icons.Default.ArrowBack,
progress = progress,
)
},
modifier = Modifier.fillMaxSize(),
) {
RootContent(
component = root,
modifier = Modifier.fillMaxSize(),
)
}
Predictive Back Gesture on iOS¶
It is possible to customize the predictive back gesture, so it looks native-ish on iOS.
@Composable
fun RootContent(component: RootComponent) {
Children(
stack = rootComponent.childStack,
animation = backAnimation(
backHandler = component.backHandler,
onBack = component::onBackClicked,
),
) {
// Omitted code
}
}
expect fun <C : Any, T : Any> backAnimation(
backHandler: BackHandler,
onBack: () -> Unit,
): StackAnimation<C, T>
actual fun <C : Any, T : Any> backAnimation(
backHandler: BackHandler,
onBack: () -> Unit,
): StackAnimation<C, T> =
predictiveBackAnimation(
backHandler = backHandler,
animation = stackAnimation(fade() + scale()),
onBack = onBack,
)
actual fun <C : Any, T : Any> backAnimation(
backHandler: BackHandler,
onBack: () -> Unit,
): StackAnimation<C, T> =
predictiveBackAnimation(
backHandler = backHandler,
animation = stackAnimation(iosLikeSlide()),
selector = { initialBackEvent, _, _ ->
predictiveBackAnimatable(
initialBackEvent = initialBackEvent,
exitModifier = { progress, _ -> Modifier.slideExitModifier(progress = progress) },
enterModifier = { progress, _ -> Modifier.slideEnterModifier(progress = progress) },
)
},
onBack = onBack,
)
private fun iosLikeSlide(animationSpec: FiniteAnimationSpec<Float> = tween()): StackAnimator =
stackAnimator(animationSpec = animationSpec) { factor, direction, content ->
content(
Modifier
.then(if (direction.isFront) Modifier else Modifier.fade(factor + 1F))
.offsetXFactor(factor = if (direction.isFront) factor else factor * 0.5F)
)
}
private fun Modifier.slideExitModifier(progress: Float): Modifier =
offsetXFactor(progress)
private fun Modifier.slideEnterModifier(progress: Float): Modifier =
fade(progress).offsetXFactor((progress - 1f) * 0.5f)
private fun Modifier.fade(factor: Float) =
drawWithContent {
drawContent()
drawRect(color = Color(red = 0F, green = 0F, blue = 0F, alpha = (1F - factor) / 4F))
}
private fun Modifier.offsetXFactor(factor: Float): Modifier =
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(x = (placeable.width.toFloat() * factor).toInt(), y = 0)
}
}
Compose for iOS, macOS and Web (Canvas)¶
Compose for iOS, macOS and Web (Canvas) is still work in progress and was not officially announced. However, Decompose already supports it. The support is also experimental and is not part of the main branch - see #74 for more information.
If you want to use Decompose with Compose for iOS/macOS/Web, you have to use special versions of both decompose
and extensions-compose-jetbrains
modules.
implementation "com.arkivanov.decompose:decompose:<version>-compose-experimental"
implementation "com.arkivanov.decompose:extensions-compose-jetbrains:<version>-compose-experimental"
implementation("com.arkivanov.decompose:decompose:<version>-compose-experimental")
implementation("com.arkivanov.decompose:extensions-compose-jetbrains:<version>-compose-experimental")
Samples¶
You can find samples in a separate branch - compose-darwin/sample/app-darwin-compose.