Repository: arkivanov/Decompose
Pull request #398 (2023-05-29 18:06:28)

Support predictive back gesture


Patch:
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/backhandler/ChildBackHandler.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/backhandler/ChildBackHandler.kt
index c8265f60..dd1f91c5 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/backhandler/ChildBackHandler.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/backhandler/ChildBackHandler.kt
@@ -1,6 +1,7 @@
 package com.arkivanov.decompose.backhandler
 
 import com.arkivanov.decompose.isDestroyed
+import com.arkivanov.essenty.backhandler.BackCallback
 import com.arkivanov.essenty.backhandler.BackHandler
 import com.arkivanov.essenty.lifecycle.Lifecycle
 import com.arkivanov.essenty.lifecycle.subscribe
@@ -14,8 +15,11 @@ internal interface ChildBackHandler : BackHandler {
     fun stop()
 }
 
-internal fun BackHandler.child(lifecycle: Lifecycle? = null): BackHandler {
-    val handler = childBackHandler(isEnabled = false)
+internal fun BackHandler.child(
+    lifecycle: Lifecycle? = null,
+    priority: Int = BackCallback.PRIORITY_DEFAULT,
+): BackHandler {
+    val handler = childBackHandler(priority = priority, isEnabled = false)
 
     if (lifecycle == null) {
         handler.isEnabled = true
@@ -34,8 +38,12 @@ internal fun BackHandler.child(lifecycle: Lifecycle? = null): BackHandler {
     return handler
 }
 
-internal fun BackHandler.childBackHandler(isEnabled: Boolean = true): ChildBackHandler =
+internal fun BackHandler.childBackHandler(
+    isEnabled: Boolean = true,
+    priority: Int = BackCallback.PRIORITY_DEFAULT,
+): ChildBackHandler =
     DefaultChildBackHandler(
         parent = this,
         isEnabled = isEnabled,
+        priority = priority,
     )
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/backhandler/DefaultChildBackHandler.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/backhandler/DefaultChildBackHandler.kt
index 96b05bb7..53d81b85 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/backhandler/DefaultChildBackHandler.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/backhandler/DefaultChildBackHandler.kt
@@ -1,18 +1,19 @@
 package com.arkivanov.decompose.backhandler
 
 import com.arkivanov.essenty.backhandler.BackCallback
+import com.arkivanov.essenty.backhandler.BackEvent
 import com.arkivanov.essenty.backhandler.BackHandler
 import kotlin.properties.Delegates.observable
 
 internal class DefaultChildBackHandler(
     private val parent: BackHandler,
     isEnabled: Boolean,
+    priority: Int,
 ) : ChildBackHandler {
 
-    private val parentCallback = BackCallback(isEnabled = false, onBack = ::onBack)
+    private val parentCallback = BackCallbackImpl(priority = priority)
     private var set = emptySet<BackCallback>()
     private val enabledChangedListener: (Boolean) -> Unit = { updateParentCallbackEnabledState() }
-
     override var isEnabled: Boolean by observable(isEnabled) { _, _, _ -> updateParentCallbackEnabledState() }
     private var isStarted = false
 
@@ -50,9 +51,24 @@ internal class DefaultChildBackHandler(
         parentCallback.isEnabled = isEnabled && set.any(BackCallback::isEnabled)
     }
 
-    private fun onBack() {
-        set.lastOrNull(BackCallback::isEnabled)?.also {
-            it.onBack()
+    private fun Iterable<BackCallback>.findMostImportant(): BackCallback? =
+        sortedBy(BackCallback::priority).lastOrNull(BackCallback::isEnabled)
+
+    private inner class BackCallbackImpl(priority: Int) : BackCallback(isEnabled = false, priority = priority) {
+        override fun onBackStarted(backEvent: BackEvent) {
+            set.findMostImportant()?.onBackStarted(backEvent)
+        }
+
+        override fun onBackProgressed(backEvent: BackEvent) {
+            set.findMostImportant()?.onBackProgressed(backEvent)
+        }
+
+        override fun onBackCancelled() {
+            set.findMostImportant()?.onBackCancelled()
+        }
+
+        override fun onBack() {
+            set.findMostImportant()?.onBack()
         }
     }
 }
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenFactory.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenFactory.kt
index 915e5ca1..3e513689 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenFactory.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenFactory.kt
@@ -82,7 +82,7 @@ fun <C : Any, T : Any, E : Any, N : NavState<C>, S : Any> ComponentContext.child
                 retainedInstanceSupplier = { factory -> instanceKeeper.getOrCreate(key = key, factory = factory) },
                 childItemFactory = DefaultChildItemFactory(
                     lifecycle = lifecycle,
-                    backHandler = backHandler.child(),
+                    backHandler = backHandler.child(priority = BackCallback.PRIORITY_DEFAULT + 1),
                     childFactory = childFactory,
                 ),
                 navState = restoredNavState ?: initialState(),
diff --git a/deps.versions.toml b/deps.versions.toml
index a549f33a..97b64d2a 100644
--- a/deps.versions.toml
+++ b/deps.versions.toml
@@ -2,7 +2,7 @@
 
 decompose = "2.0.0-alpha-02"
 kotlin = "1.8.20"
-essenty = "1.1.0"
+essenty = "1.2.0-alpha-01"
 parcelizeDarwin = "0.1.4"
 reaktive = "1.2.3"
 junit = "4.13.2"
diff --git a/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api b/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api
index 20273336..7c32efae 100644
--- a/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api
+++ b/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api
@@ -77,6 +77,11 @@ public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/an
 	public static synthetic fun fade$default (Landroidx/compose/animation/core/FiniteAnimationSpec;FILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimator;
 }
 
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/PredictiveBackAnimationKt {
+	public static final fun predictiveBackAnimation (Lcom/arkivanov/essenty/backhandler/BackHandler;Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;
+	public static synthetic fun predictiveBackAnimation$default (Lcom/arkivanov/essenty/backhandler/BackHandler;Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;
+}
+
 public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/ScaleKt {
 	public static final fun scale (Landroidx/compose/animation/core/FiniteAnimationSpec;FF)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimator;
 	public static synthetic fun scale$default (Landroidx/compose/animation/core/FiniteAnimationSpec;FFILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimator;
diff --git a/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api b/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api
index efe709aa..44731cfc 100644
--- a/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api
+++ b/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api
@@ -1,3 +1,11 @@
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/PredictiveBackGestureIconKt {
+	public static final fun PredictiveBackGestureIcon-eaDK9VM (Landroidx/compose/ui/graphics/vector/ImageVector;FJJLandroidx/compose/runtime/Composer;II)V
+}
+
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/PredictiveBackGestureOverlayKt {
+	public static final fun PredictiveBackGestureOverlay (Lcom/arkivanov/essenty/backhandler/BackDispatcher;Lkotlin/jvm/functions/Function4;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V
+}
+
 public final class com/arkivanov/decompose/extensions/compose/jetbrains/SubscribeAsStateKt {
 	public static final fun subscribeAsState (Lcom/arkivanov/decompose/value/Value;Landroidx/compose/runtime/SnapshotMutationPolicy;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State;
 }
@@ -74,6 +82,11 @@ public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/an
 	public static synthetic fun fade$default (Landroidx/compose/animation/core/FiniteAnimationSpec;FILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimator;
 }
 
+public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/PredictiveBackAnimationKt {
+	public static final fun predictiveBackAnimation (Lcom/arkivanov/essenty/backhandler/BackHandler;Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;
+	public static synthetic fun predictiveBackAnimation$default (Lcom/arkivanov/essenty/backhandler/BackHandler;Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimation;
+}
+
 public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/ScaleKt {
 	public static final fun scale (Landroidx/compose/animation/core/FiniteAnimationSpec;FF)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimator;
 	public static synthetic fun scale$default (Landroidx/compose/animation/core/FiniteAnimationSpec;FFILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimator;
diff --git a/extensions-compose-jetbrains/build.gradle.kts b/extensions-compose-jetbrains/build.gradle.kts
index b1246891..585dcc1d 100644
--- a/extensions-compose-jetbrains/build.gradle.kts
+++ b/extensions-compose-jetbrains/build.gradle.kts
@@ -1,4 +1,5 @@
 import com.arkivanov.gradle.bundle
+import com.arkivanov.gradle.dependsOn
 import com.arkivanov.gradle.setupBinaryCompatibilityValidator
 import com.arkivanov.gradle.setupMultiplatform
 import com.arkivanov.gradle.setupPublication
@@ -27,6 +28,10 @@ kotlin {
     setupSourceSets {
         val android by bundle()
         val jvm by bundle()
+        val nonAndroid by bundle()
+
+        nonAndroid dependsOn common
+        (allSet - android) dependsOn nonAndroid
 
         all {
             languageSettings {
diff --git a/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/PredictiveBackAnimation.kt b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/PredictiveBackAnimation.kt
new file mode 100644
index 00000000..4500a6c1
--- /dev/null
+++ b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/PredictiveBackAnimation.kt
@@ -0,0 +1,219 @@
+package com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.unit.dp
+import com.arkivanov.decompose.Child
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import com.arkivanov.decompose.router.stack.ChildStack
+import com.arkivanov.essenty.backhandler.BackCallback
+import com.arkivanov.essenty.backhandler.BackEvent
+import com.arkivanov.essenty.backhandler.BackHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Wraps the provided [animation], handles the predictive back gesture and animates
+ * the transition from the current [Child] to the previous one.
+ * Calls [onBack] when the animation is finished.
+ */
+@ExperimentalDecomposeApi
+fun <C : Any, T : Any> predictiveBackAnimation(
+    backHandler: BackHandler,
+    animation: StackAnimation<C, T>? = null,
+    activeModifier: (progress: Float, edge: BackEvent.SwipeEdge) -> Modifier = ::getPredictiveBackGestureAnimationModifier,
+    onBack: () -> Unit,
+): StackAnimation<C, T> =
+    PredictiveBackAnimation(
+        backHandler = backHandler,
+        animation = animation ?: emptyStackAnimation(),
+        activeModifier = activeModifier,
+        onBack = onBack,
+    )
+
+private fun getPredictiveBackGestureAnimationModifier(
+    progress: Float,
+    edge: BackEvent.SwipeEdge,
+): Modifier =
+    Modifier
+        .scale(1F - progress * 0.25F)
+        .absoluteOffset(
+            x = when (edge) {
+                BackEvent.SwipeEdge.LEFT -> 32.dp * progress
+                BackEvent.SwipeEdge.RIGHT -> (-32).dp * progress
+                BackEvent.SwipeEdge.UNKNOWN -> 0.dp
+            },
+        )
+        .alpha(((1F - progress) * 2F).coerceAtMost(1F))
+        .clip(RoundedCornerShape(percent = 10))
+
+private class PredictiveBackAnimation<C : Any, T : Any>(
+    private val backHandler: BackHandler,
+    private val animation: StackAnimation<C, T>,
+    private val activeModifier: (progress: Float, edge: BackEvent.SwipeEdge) -> Modifier,
+    private val onBack: () -> Unit,
+) : StackAnimation<C, T> {
+
+    @Composable
+    override fun invoke(stack: ChildStack<C, T>, modifier: Modifier, content: @Composable (child: Child.Created<C, T>) -> Unit) {
+        val currentKey = remember { Holder(value = 0) }
+
+        var items: List<Item<C, T>> by rememberMutableStateWithLatest(
+            key = stack,
+            onReplaced = { latestItems -> currentKey.value = latestItems.maxOf(Item<*, *>::key) },
+            getValue = { listOf(Item(stack = stack, key = currentKey.value)) },
+        )
+
+        Box(modifier = modifier) {
+            items.forEach { item ->
+                key(item.key) {
+                    animation(
+                        stack = item.stack,
+                        modifier = Modifier.fillMaxSize().then(item.progressData?.toProgressModifier() ?: Modifier),
+                        content = content,
+                    )
+                }
+            }
+        }
+
+        DisposableEffect(stack) {
+            if (stack.backStack.isEmpty()) {
+                return@DisposableEffect onDispose {}
+            }
+
+            val scope = CoroutineScope(Dispatchers.Main.immediate)
+
+            val callback =
+                GestureBackCallback(
+                    scope = scope,
+                    stack = stack,
+                    currentKey = currentKey.value,
+                    setItems = { items = it },
+                    onFinished = {
+                        currentKey.value++
+                        onBack()
+                    },
+                )
+
+            backHandler.register(callback)
+
+            onDispose {
+                scope.cancel()
+                backHandler.unregister(callback)
+            }
+        }
+    }
+
+    @Composable
+    private fun <T : Any> rememberMutableStateWithLatest(
+        key: Any,
+        onReplaced: (latestValue: T) -> Unit,
+        getValue: () -> T,
+    ): MutableState<T> {
+        val latestValue: Holder<T?> = remember { Holder(value = null) }
+
+        val state =
+            remember(key) {
+                latestValue.value?.also(onReplaced)
+                mutableStateOf(getValue())
+            }
+
+        latestValue.value = state.value
+
+        return state
+    }
+
+    private fun ProgressData.toProgressModifier(): Modifier =
+        activeModifier(progress, edge)
+
+    private data class Item<out C : Any, out T : Any>(
+        val stack: ChildStack<C, T>,
+        val key: Int,
+        val progressData: ProgressData? = null,
+    )
+
+    private data class ProgressData(
+        val progress: Float,
+        val edge: BackEvent.SwipeEdge,
+    )
+
+    private class Holder<T>(var value: T)
+
+    private class GestureBackCallback<C : Any, T : Any>(
+        private val scope: CoroutineScope,
+        stack: ChildStack<C, T>,
+        currentKey: Int,
+        private val setItems: (List<Item<C, T>>) -> Unit,
+        private val onFinished: (newKey: Int) -> Unit,
+    ) : BackCallback() {
+        private val nextKey = currentKey + 1
+
+        private val previousItem =
+            Item(
+                stack = ChildStack(
+                    active = stack.backStack.last(),
+                    backStack = stack.backStack.dropLast(1),
+                ),
+                key = nextKey,
+            )
+
+        private var currentItem = Item(stack = stack, key = currentKey)
+
+        override fun onBackStarted(backEvent: BackEvent) {
+            currentItem = currentItem.copy(progressData = backEvent.toProgressData())
+            setItems(listOf(previousItem, currentItem))
+        }
+
+        override fun onBackProgressed(backEvent: BackEvent) {
+            currentItem = currentItem.copy(progressData = backEvent.toProgressData())
+            setItems(listOf(previousItem, currentItem))
+        }
+
+        override fun onBackCancelled() {
+            setItems(listOf(currentItem.copy(progressData = null)))
+        }
+
+        override fun onBack() {
+            scope.launch { continueGesture() }
+        }
+
+        private suspend fun CoroutineScope.continueGesture() {
+            currentItem.progressData?.also { progressData ->
+                var progress = progressData.progress
+                while ((progress <= 1F) && isActive) {
+                    delay(16.milliseconds)
+                    progress += 0.075F
+                    currentItem = currentItem.copy(progressData = progressData.copy(progress = progress))
+                    setItems(listOf(previousItem, currentItem))
+                }
+            }
+
+            if (isActive) {
+                setItems(listOf(previousItem))
+                onFinished(nextKey)
+            }
+        }
+
+        private fun BackEvent.toProgressData(): ProgressData =
+            ProgressData(progress = progress, edge = swipeEdge)
+    }
+}
diff --git a/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/utils/Icon.kt b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/utils/Icon.kt
new file mode 100644
index 00000000..61ed4de6
--- /dev/null
+++ b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/utils/Icon.kt
@@ -0,0 +1,43 @@
+package com.arkivanov.decompose.extensions.compose.jetbrains.utils
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.paint
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.toolingGraphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+
+// Avoid dependency on compose.material
+@Composable
+internal fun IconCompat(
+    painter: Painter,
+    modifier: Modifier = Modifier,
+    tint: Color = Color.Unspecified,
+) {
+    Box(
+        modifier
+            .toolingGraphicsLayer()
+            .defaultSizeFor(painter)
+            .paint(
+                painter = painter,
+                colorFilter = if (tint == Color.Unspecified) null else ColorFilter.tint(tint),
+                contentScale = ContentScale.Fit,
+            )
+    )
+}
+
+private fun Modifier.defaultSizeFor(painter: Painter): Modifier =
+    if ((painter.intrinsicSize == Size.Unspecified) || painter.intrinsicSize.isInfinite()) {
+        then(Modifier.size(24.dp))
+    } else {
+        this
+    }
+
+private fun Size.isInfinite(): Boolean =
+    width.isInfinite() && height.isInfinite()
diff --git a/extensions-compose-jetbrains/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/PredictiveBackGestureIcon.kt b/extensions-compose-jetbrains/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/PredictiveBackGestureIcon.kt
new file mode 100644
index 00000000..6524a850
--- /dev/null
+++ b/extensions-compose-jetbrains/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/PredictiveBackGestureIcon.kt
@@ -0,0 +1,35 @@
+package com.arkivanov.decompose.extensions.compose.jetbrains
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.unit.dp
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import com.arkivanov.decompose.extensions.compose.jetbrains.utils.IconCompat
+
+/**
+ * Default icon for predictive back gesture.
+ */
+@ExperimentalDecomposeApi
+@Composable
+fun PredictiveBackGestureIcon(
+    imageVector: ImageVector,
+    progress: Float,
+    iconTintColor: Color = Color.Unspecified,
+    backgroundColor: Color = Color.LightGray,
+) {
+    IconCompat(
+        painter = rememberVectorPainter(imageVector),
+        modifier = Modifier
+            .alpha((progress * 6F).coerceAtMost(1F))
+            .background(color = backgroundColor, shape = CircleShape)
+            .padding(4.dp),
+        tint = iconTintColor,
+    )
+}
diff --git a/extensions-compose-jetbrains/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/PredictiveBackGestureOverlay.kt b/extensions-compose-jetbrains/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/PredictiveBackGestureOverlay.kt
new file mode 100644
index 00000000..c000925b
--- /dev/null
+++ b/extensions-compose-jetbrains/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/PredictiveBackGestureOverlay.kt
@@ -0,0 +1,287 @@
+package com.arkivanov.decompose.extensions.compose.jetbrains
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.AwaitPointerEventScope
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import com.arkivanov.essenty.backhandler.BackCallback
+import com.arkivanov.essenty.backhandler.BackDispatcher
+import com.arkivanov.essenty.backhandler.BackDispatcher.PredictiveBackDispatcher
+import com.arkivanov.essenty.backhandler.BackEvent
+
+/**
+ * Handles back gestures on both edges of the screen and drives the provided [backDispatcher] accordingly,
+ * while showing the provided [backIcon].
+ *
+ * @param backDispatcher [BackDispatcher] where callbacks should be registered.
+ * @param backIcon an icon to be shown while the gesture is being performed. A default icon can be shown
+ * using [PredictiveBackGestureIcon].
+ * @param modifier a [Modifier] to applied to the overlay.
+ * @param onClose If supplied, then the back gesture is also handled when there are no other enabled back
+ * callbacks registered in [backDispatcher], can be used to close the application.
+ * @param content a content to be shown under the overlay.
+ */
+@ExperimentalDecomposeApi
+@Composable
+fun PredictiveBackGestureOverlay(
+    backDispatcher: BackDispatcher,
+    backIcon: (@Composable (progress: Float, edge: BackEvent.SwipeEdge) -> Unit)?,
+    modifier: Modifier = Modifier,
+    onClose: (() -> Unit)? = null,
+    content: @Composable () -> Unit,
+) {
+    val iconState: MutableState<IconState> = remember { mutableStateOf(IconState()) }
+
+    Box(
+        modifier = modifier.handleBackGestures(
+            backDispatcher = backDispatcher,
+            onIconMoved = { position, progress, edge ->
+                iconState.value =
+                    IconState(
+                        position = position,
+                        progress = progress,
+                        edge = edge,
+                        isVisible = true,
+                    )
+            },
+            onIconHidden = { iconState.value = iconState.value.copy(isVisible = false) }
+        ),
+    ) {
+        content()
+
+        if (backIcon != null) {
+            val (position, progress, edge, isVisible) = iconState.value
+
+            AnimatedVisibility(
+                visible = isVisible,
+                modifier = Modifier.backIconOffset(position = position),
+                enter = fadeIn(),
+                exit = fadeOut(),
+                content = { backIcon(progress, edge.toSwipeEdge()) },
+            )
+        }
+    }
+
+    if (onClose != null) {
+        DisposableEffect(backDispatcher, onClose) {
+            val callback = BackCallback(priority = BackCallback.PRIORITY_MIN, onBack = onClose)
+            backDispatcher.register(callback)
+            onDispose { backDispatcher.unregister(callback) }
+        }
+    }
+}
+
+private fun Modifier.handleBackGestures(
+    backDispatcher: BackDispatcher,
+    onIconMoved: (position: Offset, progress: Float, BackGestureHandler.Edge) -> Unit,
+    onIconHidden: () -> Unit,
+): Modifier =
+    pointerInput(backDispatcher) {
+        awaitEachGesture {
+            onIconHidden()
+
+            val down = awaitFirstDown(pass = PointerEventPass.Initial)
+            val startPosition = down.position
+
+            if ((startPosition.x > 16.dp.toPx()) && (startPosition.x < size.width - 16.dp.toPx())) {
+                return@awaitEachGesture
+            }
+
+            val handler =
+                BackGestureHandler(
+                    startPosition = startPosition,
+                    size = size,
+                    cancelYOffset = 16.dp.toPx(),
+                    startXOffset = 16.dp.toPx(),
+                    backDispatcher = backDispatcher,
+                    onIconMoved = onIconMoved,
+                )
+
+            with(handler) { handleGesture() }
+        }
+    }
+
+private fun Modifier.backIconOffset(position: Offset): Modifier =
+    layout { measurable, constraints ->
+        val placeable = measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
+        layout(constraints.maxWidth, constraints.minHeight) {
+            placeable.placeRelative(
+                x = (position.x.toInt() - placeable.width / 2),
+                y = (position.y.toInt() - 48.dp.roundToPx()).coerceAtLeast(64.dp.roundToPx()),
+            )
+        }
+    }
+
+private fun BackGestureHandler.Edge.toSwipeEdge(): BackEvent.SwipeEdge =
+    when (this) {
+        BackGestureHandler.Edge.LEFT -> BackEvent.SwipeEdge.LEFT
+        BackGestureHandler.Edge.RIGHT -> BackEvent.SwipeEdge.RIGHT
+    }
+
+private data class IconState(
+    val position: Offset = Offset.Zero,
+    val progress: Float = 0F,
+    val edge: BackGestureHandler.Edge = BackGestureHandler.Edge.RIGHT,
+    val isVisible: Boolean = false,
+)
+
+private class BackGestureHandler(
+    private val startPosition: Offset,
+    private val size: IntSize,
+    private val cancelYOffset: Float,
+    private val startXOffset: Float,
+    private val backDispatcher: BackDispatcher,
+    private val onIconMoved: (position: Offset, progress: Float, Edge) -> Unit,
+) {
+
+    private val edge = if (startPosition.x < size.width / 2F) Edge.LEFT else Edge.RIGHT
+    private var changesIterator: Iterator<PointerInputChange>? = null
+
+    private suspend fun AwaitPointerEventScope.awaitChange(): PointerInputChange {
+        var iterator = changesIterator
+
+        if ((iterator == null) || !iterator.hasNext()) {
+            iterator = awaitPointerEvent(pass = PointerEventPass.Initial).changes.iterator()
+            changesIterator = iterator
+        }
+
+        return iterator.next()
+    }
+
+    suspend fun AwaitPointerEventScope.handleGesture() {
+        val dispatcher: PredictiveBackDispatcher = awaitStart() ?: return
+        processGesture(dispatcher)
+    }
+
+    private suspend fun AwaitPointerEventScope.awaitStartChange(): PointerInputChange? {
+        while (true) {
+            val change = awaitChange()
+            val position = change.position
+
+            if ((position.y < startPosition.y - cancelYOffset) || (position.y > startPosition.y + cancelYOffset)) {
+                return null
+            }
+
+            when (edge) {
+                Edge.LEFT ->
+                    if (position.x > startPosition.x + startXOffset) {
+                        return change
+                    }
+
+                Edge.RIGHT ->
+                    if (position.x < startPosition.x - startXOffset) {
+                        return change
+                    }
+            }
+        }
+    }
+
+    private suspend fun AwaitPointerEventScope.awaitStart(): PredictiveBackDispatcher? {
+        val change = awaitStartChange() ?: return null
+        val position = change.position
+
+        val dispatcher =
+            backDispatcher.startPredictiveBack(
+                BackEvent(
+                    progress = getProgress(position = position),
+                    swipeEdge = edge.toSwipeEdge(),
+                    touchX = position.x,
+                    touchY = position.y,
+                )
+            ) ?: return null
+
+        change.consume()
+
+        return dispatcher
+    }
+
+    private suspend fun AwaitPointerEventScope.processGesture(dispatcher: PredictiveBackDispatcher) {
+        while (true) {
+            val change = awaitChange()
+            val position = change.position
+            change.consume()
+
+            val progress = getProgress(position = position)
+
+            dispatcher.progress(
+                BackEvent(
+                    progress = progress,
+                    swipeEdge = edge.toSwipeEdge(),
+                    touchX = position.x,
+                    touchY = position.y,
+                )
+            )
+
+            if (!change.pressed) {
+                if (progress > 0.2F) {
+                    dispatcher.finish()
+                } else {
+                    dispatcher.cancel()
+                }
+
+                return
+            }
+
+            onIconMoved(getIconOffset(progress = progress, position = position), progress, edge)
+        }
+    }
+
+    private fun getProgress(position: Offset): Float =
+        when (edge) {
+            Edge.LEFT -> {
+                val startX = startPosition.x + startXOffset
+                (position.x - startX) / (size.width - startX)
+            }
+
+            Edge.RIGHT -> {
+                val startX = startPosition.x - startXOffset
+                (startX - position.x) / startX
+            }
+        }.coerceIn(0F, 1F)
+
+    private fun getIconOffset(progress: Float, position: Offset): Offset =
+        Offset(
+            x = getChevronPositionX(progress = progress, position = position),
+            y = getChevronPositionY(position = position),
+        )
+
+    private fun getChevronPositionX(progress: Float, position: Offset): Float {
+        val factor = 2F + 3F * progress
+
+        return when (edge) {
+            Edge.LEFT -> position.x / factor
+            Edge.RIGHT -> startPosition.x + (position.x - startPosition.x) / factor
+        }
+    }
+
+    private fun getChevronPositionY(position: Offset): Float =
+        startPosition.y + (position.y - startPosition.y) / 4F
+
+    private fun Edge.toSwipeEdge(): BackEvent.SwipeEdge =
+        when (this) {
+            Edge.LEFT -> BackEvent.SwipeEdge.LEFT
+            Edge.RIGHT -> BackEvent.SwipeEdge.RIGHT
+        }
+
+    enum class Edge {
+        LEFT,
+        RIGHT,
+    }
+}
diff --git a/extensions-compose-jetpack/api/extensions-compose-jetpack.api b/extensions-compose-jetpack/api/extensions-compose-jetpack.api
index 5504df4d..5a89f035 100644
--- a/extensions-compose-jetpack/api/extensions-compose-jetpack.api
+++ b/extensions-compose-jetpack/api/extensions-compose-jetpack.api
@@ -77,6 +77,11 @@ public final class com/arkivanov/decompose/extensions/compose/jetpack/stack/anim
 	public static synthetic fun fade$default (Landroidx/compose/animation/core/FiniteAnimationSpec;FILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetpack/stack/animation/StackAnimator;
 }
 
+public final class com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/PredictiveBackAnimationKt {
+	public static final fun predictiveBackAnimation (Lcom/arkivanov/essenty/backhandler/BackHandler;Lcom/arkivanov/decompose/extensions/compose/jetpack/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;)Lcom/arkivanov/decompose/extensions/compose/jetpack/stack/animation/StackAnimation;
+	public static synthetic fun predictiveBackAnimation$default (Lcom/arkivanov/essenty/backhandler/BackHandler;Lcom/arkivanov/decompose/extensions/compose/jetpack/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetpack/stack/animation/StackAnimation;
+}
+
 public final class com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/ScaleKt {
 	public static final fun scale (Landroidx/compose/animation/core/FiniteAnimationSpec;FF)Lcom/arkivanov/decompose/extensions/compose/jetpack/stack/animation/StackAnimator;
 	public static synthetic fun scale$default (Landroidx/compose/animation/core/FiniteAnimationSpec;FFILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetpack/stack/animation/StackAnimator;
diff --git a/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/PredictiveBackAnimation.kt b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/PredictiveBackAnimation.kt
new file mode 100644
index 00000000..c44c7634
--- /dev/null
+++ b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/PredictiveBackAnimation.kt
@@ -0,0 +1,221 @@
+package com.arkivanov.decompose.extensions.compose.jetpack.stack.animation
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.unit.dp
+import com.arkivanov.decompose.Child
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import com.arkivanov.decompose.router.stack.ChildStack
+import com.arkivanov.essenty.backhandler.BackCallback
+import com.arkivanov.essenty.backhandler.BackEvent
+import com.arkivanov.essenty.backhandler.BackHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Wraps the provided [animation], handles the predictive back gesture and animates
+ * the transition from the current [Child] to the previous one.
+ * Calls [onBack] when the animation is finished.
+ */
+@ExperimentalDecomposeApi
+fun <C : Any, T : Any> predictiveBackAnimation(
+    backHandler: BackHandler,
+    animation: StackAnimation<C, T>? = null,
+    activeModifier: (progress: Float, edge: BackEvent.SwipeEdge) -> Modifier = ::getPredictiveBackGestureAnimationModifier,
+    onBack: () -> Unit,
+): StackAnimation<C, T> =
+    PredictiveBackAnimation(
+        backHandler = backHandler,
+        animation = animation ?: emptyStackAnimation(),
+        activeModifier = activeModifier,
+        onBack = onBack,
+    )
+
+@SuppressLint("ModifierFactoryExtensionFunction")
+private fun getPredictiveBackGestureAnimationModifier(
+    progress: Float,
+    edge: BackEvent.SwipeEdge,
+): Modifier =
+    Modifier
+        .scale(1F - progress * 0.25F)
+        .absoluteOffset(
+            x = when (edge) {
+                BackEvent.SwipeEdge.LEFT -> 32.dp * progress
+                BackEvent.SwipeEdge.RIGHT -> (-32).dp * progress
+                BackEvent.SwipeEdge.UNKNOWN -> 0.dp
+            },
+        )
+        .alpha(((1F - progress) * 2F).coerceAtMost(1F))
+        .clip(RoundedCornerShape(percent = 10))
+
+private class PredictiveBackAnimation<C : Any, T : Any>(
+    private val backHandler: BackHandler,
+    private val animation: StackAnimation<C, T>,
+    private val activeModifier: (progress: Float, edge: BackEvent.SwipeEdge) -> Modifier,
+    private val onBack: () -> Unit,
+) : StackAnimation<C, T> {
+
+    @Composable
+    override fun invoke(stack: ChildStack<C, T>, modifier: Modifier, content: @Composable (child: Child.Created<C, T>) -> Unit) {
+        val currentKey = remember { Holder(value = 0) }
+
+        var items: List<Item<C, T>> by rememberMutableStateWithLatest(
+            key = stack,
+            onReplaced = { latestItems -> currentKey.value = latestItems.maxOf(Item<*, *>::key) },
+            getValue = { listOf(Item(stack = stack, key = currentKey.value)) },
+        )
+
+        Box(modifier = modifier) {
+            items.forEach { item ->
+                key(item.key) {
+                    animation(
+                        stack = item.stack,
+                        modifier = Modifier.fillMaxSize().then(item.progressData?.toProgressModifier() ?: Modifier),
+                        content = content,
+                    )
+                }
+            }
+        }
+
+        DisposableEffect(stack) {
+            if (stack.backStack.isEmpty()) {
+                return@DisposableEffect onDispose {}
+            }
+
+            val scope = CoroutineScope(Dispatchers.Main.immediate)
+
+            val callback =
+                GestureBackCallback(
+                    scope = scope,
+                    stack = stack,
+                    currentKey = currentKey.value,
+                    setItems = { items = it },
+                    onFinished = {
+                        currentKey.value++
+                        onBack()
+                    },
+                )
+
+            backHandler.register(callback)
+
+            onDispose {
+                scope.cancel()
+                backHandler.unregister(callback)
+            }
+        }
+    }
+
+    @Composable
+    private fun <T : Any> rememberMutableStateWithLatest(
+        key: Any,
+        onReplaced: (latestValue: T) -> Unit,
+        getValue: () -> T,
+    ): MutableState<T> {
+        val latestValue: Holder<T?> = remember { Holder(value = null) }
+
+        val state =
+            remember(key) {
+                latestValue.value?.also(onReplaced)
+                mutableStateOf(getValue())
+            }
+
+        latestValue.value = state.value
+
+        return state
+    }
+
+    private fun ProgressData.toProgressModifier(): Modifier =
+        activeModifier(progress, edge)
+
+    private data class Item<out C : Any, out T : Any>(
+        val stack: ChildStack<C, T>,
+        val key: Int,
+        val progressData: ProgressData? = null,
+    )
+
+    private data class ProgressData(
+        val progress: Float,
+        val edge: BackEvent.SwipeEdge,
+    )
+
+    private class Holder<T>(var value: T)
+
+    private class GestureBackCallback<C : Any, T : Any>(
+        private val scope: CoroutineScope,
+        stack: ChildStack<C, T>,
+        currentKey: Int,
+        private val setItems: (List<Item<C, T>>) -> Unit,
+        private val onFinished: (newKey: Int) -> Unit,
+    ) : BackCallback() {
+        private val nextKey = currentKey + 1
+
+        private val previousItem =
+            Item(
+                stack = ChildStack(
+                    active = stack.backStack.last(),
+                    backStack = stack.backStack.dropLast(1),
+                ),
+                key = nextKey,
+            )
+
+        private var currentItem = Item(stack = stack, key = currentKey)
+
+        override fun onBackStarted(backEvent: BackEvent) {
+            currentItem = currentItem.copy(progressData = backEvent.toProgressData())
+            setItems(listOf(previousItem, currentItem))
+        }
+
+        override fun onBackProgressed(backEvent: BackEvent) {
+            currentItem = currentItem.copy(progressData = backEvent.toProgressData())
+            setItems(listOf(previousItem, currentItem))
+        }
+
+        override fun onBackCancelled() {
+            setItems(listOf(currentItem.copy(progressData = null)))
+        }
+
+        override fun onBack() {
+            scope.launch { continueGesture() }
+        }
+
+        private suspend fun CoroutineScope.continueGesture() {
+            currentItem.progressData?.also { progressData ->
+                var progress = progressData.progress
+                while ((progress <= 1F) && isActive) {
+                    delay(16.milliseconds)
+                    progress += 0.075F
+                    currentItem = currentItem.copy(progressData = progressData.copy(progress = progress))
+                    setItems(listOf(previousItem, currentItem))
+                }
+            }
+
+            if (isActive) {
+                setItems(listOf(previousItem))
+                onFinished(nextKey)
+            }
+        }
+
+        private fun BackEvent.toProgressData(): ProgressData =
+            ProgressData(progress = progress, edge = swipeEdge)
+    }
+}
diff --git a/sample/app-ios/app-ios/CountersView.swift b/sample/app-ios/app-ios/CountersView.swift
index dee89d6a..a1be1a78 100644
--- a/sample/app-ios/app-ios/CountersView.swift
+++ b/sample/app-ios/app-ios/CountersView.swift
@@ -32,8 +32,9 @@ struct CountersView_Previews: PreviewProvider {
 }
 
 class PreviewCountersComponent: CountersComponent {
-    let childStack: Value<ChildStack<AnyObject, CounterComponent>> =
-    simpleChildStack(PreviewCounterComponent())
+    var backHandler: BackHandler = BackDispatcherKt.BackDispatcher()
+    let childStack: Value<ChildStack<AnyObject, CounterComponent>> = simpleChildStack(PreviewCounterComponent())
     
     func onBackClicked(toIndex: Int32) {}
+    func onBackClicked() {}
 }
diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersContent.kt
index b2abf013..1ef37af5 100644
--- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersContent.kt
+++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersContent.kt
@@ -10,12 +10,15 @@ import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children
 import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.fade
 import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.isFront
 import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.plus
+import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.predictiveBackAnimation
 import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.scale
 import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.slide
 import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation
 import com.arkivanov.decompose.router.stack.ChildStack
 import com.arkivanov.decompose.value.MutableValue
 import com.arkivanov.decompose.value.Value
+import com.arkivanov.essenty.backhandler.BackDispatcher
+import com.arkivanov.essenty.backhandler.BackHandler
 import com.arkivanov.sample.shared.counters.counter.CounterComponent
 import com.arkivanov.sample.shared.counters.counter.CounterContent
 import com.arkivanov.sample.shared.counters.counter.PreviewCounterComponent
@@ -25,13 +28,17 @@ internal fun CountersContent(component: CountersComponent, modifier: Modifier =
     Children(
         stack = component.childStack,
         modifier = modifier,
-        animation = stackAnimation { _, _, direction ->
-            if (direction.isFront) {
-                slide() + fade()
-            } else {
-                scale(frontFactor = 1F, backFactor = 0.7F) + fade()
-            }
-        },
+        animation = predictiveBackAnimation(
+            backHandler = component.backHandler,
+            animation = stackAnimation { _, _, direction ->
+                if (direction.isFront) {
+                    slide() + fade()
+                } else {
+                    scale(frontFactor = 1F, backFactor = 0.7F) + fade()
+                }
+            },
+            onBack = component::onBackClicked,
+        ),
     ) {
         CounterContent(
             component = it.instance,
@@ -47,6 +54,8 @@ internal fun CountersPreview() {
 }
 
 internal class PreviewCountersComponent : CountersComponent {
+    override val backHandler: BackHandler = BackDispatcher()
+
     override val childStack: Value<ChildStack<*, CounterComponent>> =
         MutableValue(
             ChildStack(
@@ -55,5 +64,6 @@ internal class PreviewCountersComponent : CountersComponent {
             )
         )
 
+    override fun onBackClicked() {}
     override fun onBackClicked(toIndex: Int) {}
 }
diff --git a/sample/shared/shared/build.gradle.kts b/sample/shared/shared/build.gradle.kts
index 2988c465..15eabae9 100644
--- a/sample/shared/shared/build.gradle.kts
+++ b/sample/shared/shared/build.gradle.kts
@@ -37,6 +37,7 @@ kotlin {
                     baseName = "Shared"
                     export(project(":decompose"))
                     export(deps.essenty.lifecycle)
+                    export(deps.essenty.backHandler)
 
                     // Optional, only if you need state preservation on Darwin (Apple) targets
                     export(deps.essenty.stateKeeper)
@@ -60,6 +61,7 @@ kotlin {
             implementation(project(":sample:shared:dynamic-features:api"))
             api(deps.essenty.lifecycle)
             api(deps.essenty.stateKeeper)
+            api(deps.essenty.backHandler)
             implementation(deps.reaktive.reaktive)
         }
 
diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersComponent.kt
index 77a35666..fc8d0160 100644
--- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersComponent.kt
+++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersComponent.kt
@@ -2,11 +2,13 @@ package com.arkivanov.sample.shared.counters
 
 import com.arkivanov.decompose.router.stack.ChildStack
 import com.arkivanov.decompose.value.Value
+import com.arkivanov.essenty.backhandler.BackHandlerOwner
 import com.arkivanov.sample.shared.counters.counter.CounterComponent
 
-interface CountersComponent {
+interface CountersComponent : BackHandlerOwner {
 
     val childStack: Value<ChildStack<*, CounterComponent>>
 
+    fun onBackClicked()
     fun onBackClicked(toIndex: Int)
 }
diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/DefaultCountersComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/DefaultCountersComponent.kt
index 2cd07a25..8b24c251 100644
--- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/DefaultCountersComponent.kt
+++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/DefaultCountersComponent.kt
@@ -4,7 +4,6 @@ import com.arkivanov.decompose.ComponentContext
 import com.arkivanov.decompose.router.stack.ChildStack
 import com.arkivanov.decompose.router.stack.StackNavigation
 import com.arkivanov.decompose.router.stack.childStack
-import com.arkivanov.decompose.router.stack.navigate
 import com.arkivanov.decompose.router.stack.pop
 import com.arkivanov.decompose.router.stack.popTo
 import com.arkivanov.decompose.router.stack.push
@@ -41,6 +40,10 @@ internal class DefaultCountersComponent(
             onPrev = navigation::pop,
         )
 
+    override fun onBackClicked() {
+        navigation.pop()
+    }
+
     override fun onBackClicked(toIndex: Int) {
         navigation.popTo(index = toIndex)
     }


Test patch:
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/backhandler/DefaultChildBackHandlerTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/backhandler/DefaultChildBackHandlerTest.kt
index 7af49ea2..ae9cad7a 100644
--- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/backhandler/DefaultChildBackHandlerTest.kt
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/backhandler/DefaultChildBackHandlerTest.kt
@@ -234,19 +234,31 @@ class DefaultChildBackHandlerTest {
         val handler = handler(isEnabled = true)
         handler.start()
         val items = ArrayList<Int>()
-        handler.register(callback(isEnabled = true) { items += 1 })
-        handler.register(callback(isEnabled = false) { items += 2 })
-        handler.register(callback(isEnabled = true) { items += 3 })
-        handler.register(callback(isEnabled = false) { items += 4 })
+        handler.register(callback(isEnabled = true, priority = 0) { items += 1 })
+        handler.register(callback(isEnabled = false, priority = 0) { items += 2 })
+        handler.register(callback(isEnabled = true, priority = 2) { items += 3 })
+        handler.register(callback(isEnabled = false, priority = 2) { items += 4 })
+        handler.register(callback(isEnabled = true, priority = 2) { items += 5 })
+        handler.register(callback(isEnabled = false, priority = 2) { items += 6 })
+        handler.register(callback(isEnabled = true, priority = 1) { items += 7 })
+        handler.register(callback(isEnabled = false, priority = 1) { items += 8 })
 
         parent.back()
 
-        assertEquals(listOf(3), items)
+        assertEquals(listOf(5), items)
     }
 
     private fun handler(isEnabled: Boolean = false): DefaultChildBackHandler =
-        DefaultChildBackHandler(parent = parent, isEnabled = isEnabled)
-
-    private fun callback(isEnabled: Boolean = true, onBack: () -> Unit = {}): BackCallback =
-        BackCallback(isEnabled = isEnabled, onBack = onBack)
+        DefaultChildBackHandler(parent = parent, isEnabled = isEnabled, priority = 0)
+
+    private fun callback(
+        isEnabled: Boolean = true,
+        priority: Int = BackCallback.PRIORITY_DEFAULT,
+        onBack: () -> Unit = {},
+    ): BackCallback =
+        BackCallback(
+            isEnabled = isEnabled,
+            priority = priority,
+            onBack = onBack,
+        )
 }