Repository: arkivanov/Decompose
Pull request #633 (2024-02-10 15:49:22)

Fixed animation on hardware back button click with predictive back gesture enabled


Patch:
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 851996e8..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,7 +1,7 @@
 package com.arkivanov.decompose.backhandler
 
 import com.arkivanov.essenty.backhandler.BackCallback
-import com.arkivanov.essenty.backhandler.BackDispatcher
+import com.arkivanov.essenty.backhandler.BackEvent
 import com.arkivanov.essenty.backhandler.BackHandler
 import kotlin.properties.Delegates.observable
 
@@ -9,38 +9,66 @@ internal class DefaultChildBackHandler(
     private val parent: BackHandler,
     isEnabled: Boolean,
     priority: Int,
-    private val delegate: BackDispatcher = BackDispatcher(),
-) : ChildBackHandler, BackHandler by delegate {
-
-    private val parentCallback: BackCallback =
-        BackCallback(
-            isEnabled = false,
-            priority = priority,
-            onBackStarted = delegate::startPredictiveBack,
-            onBackProgressed = delegate::progressPredictiveBack,
-            onBackCancelled = delegate::cancelPredictiveBack,
-            onBack = delegate::back,
-        )
+) : ChildBackHandler {
 
+    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() }
-
-    init {
-        delegate.addEnabledChangedListener { updateParentCallbackEnabledState() }
-    }
+    private var isStarted = false
 
     override fun start() {
-        if (!parent.isRegistered(parentCallback)) {
+        if (!isStarted) {
+            isStarted = true
             parent.register(parentCallback)
         }
     }
 
     override fun stop() {
-        if (parent.isRegistered(parentCallback)) {
+        if (isStarted) {
+            isStarted = false
             parent.unregister(parentCallback)
         }
     }
 
+    override fun register(callback: BackCallback) {
+        check(callback !in set) { "Callback is already registered" }
+
+        this.set += callback
+        callback.addEnabledChangedListener(enabledChangedListener)
+        updateParentCallbackEnabledState()
+    }
+
+    override fun unregister(callback: BackCallback) {
+        check(callback in set) { "Callback is not registered" }
+
+        callback.removeEnabledChangedListener(enabledChangedListener)
+        this.set -= callback
+        updateParentCallbackEnabledState()
+    }
+
     private fun updateParentCallbackEnabledState() {
-        parentCallback.isEnabled = isEnabled && delegate.isEnabled
+        parentCallback.isEnabled = isEnabled && set.any(BackCallback::isEnabled)
+    }
+
+    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/deps.versions.toml b/deps.versions.toml
index cfc0e78c..f0282424 100644
--- a/deps.versions.toml
+++ b/deps.versions.toml
@@ -2,7 +2,7 @@
 
 decompose = "3.0.0-alpha05"
 kotlin = "1.9.21"
-essenty = "2.0.0-alpha06"
+essenty = "2.0.0-alpha03"
 reaktive = "1.2.3"
 junit = "4.13.2"
 jetbrainsCompose = "1.6.0-alpha01"
diff --git a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackAnimation.kt b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackAnimation.kt
index 610b8475..c30ff030 100644
--- a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackAnimation.kt
+++ b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackAnimation.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import com.arkivanov.decompose.Child
 import com.arkivanov.decompose.ExperimentalDecomposeApi
+import com.arkivanov.decompose.Ref
 import com.arkivanov.decompose.extensions.compose.stack.animation.LocalStackAnimationProvider
 import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimation
 import com.arkivanov.decompose.extensions.compose.stack.animation.emptyStackAnimation
@@ -116,22 +117,30 @@ private class PredictiveBackAnimation<C : Any, T : Any>(
         if (isBackEnabled) {
             if (isBackGestureEnabled) {
                 val scope = rememberCoroutineScope()
+                val initialBackEventRef = remember { Ref<BackEvent?>(null) }
 
                 BackGestureHandler(
                     backHandler = backHandler,
-                    onBackStarted = {
-                        data = data.copy(animatable = selector(it, data.stack.active, data.stack.backStack.last()))
-                    },
+                    onBackStarted = { initialBackEventRef.value = it },
                     onBackProgressed = {
+                        initialBackEventRef.value?.also { initialBackEvent ->
+                            data = data.copy(animatable = selector(initialBackEvent, data.stack.active, data.stack.backStack.last()))
+                            initialBackEventRef.value = null
+                        }
+
                         scope.launch { data.animatable?.animate(it) }
                     },
                     onBackCancelled = {
+                        initialBackEventRef.value = null
+
                         scope.launch {
                             data.animatable?.cancel()
                             data = data.copy(animatable = null)
                         }
                     },
                     onBack = {
+                        initialBackEventRef.value = null
+
                         if (data.animatable == null) {
                             onBack()
                         } else {
diff --git a/extensions-compose/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackGestureOverlay.kt b/extensions-compose/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackGestureOverlay.kt
index 29d316c6..93bfc589 100644
--- a/extensions-compose/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackGestureOverlay.kt
+++ b/extensions-compose/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackGestureOverlay.kt
@@ -26,6 +26,7 @@ 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
 import com.arkivanov.essenty.backhandler.BackEvent.SwipeEdge
 
@@ -206,9 +207,8 @@ private class BackGestureHandler(
     }
 
     suspend fun AwaitPointerEventScope.handleGesture() {
-        if (awaitStart()) {
-            processGesture()
-        }
+        val dispatcher: PredictiveBackDispatcher = awaitStart() ?: return
+        processGesture(dispatcher)
     }
 
     private suspend fun AwaitPointerEventScope.awaitStartChange(): PointerInputChange? {
@@ -234,22 +234,26 @@ private class BackGestureHandler(
         }
     }
 
-    private suspend fun AwaitPointerEventScope.awaitStart(): Boolean {
-        val change = awaitStartChange() ?: return false
-        change.consume()
+    private suspend fun AwaitPointerEventScope.awaitStart(): PredictiveBackDispatcher? {
+        val change = awaitStartChange() ?: return null
         val position = change.position
 
-        return backDispatcher.startPredictiveBack(
-            BackEvent(
-                progress = getProgress(position = position),
-                swipeEdge = edge.toSwipeEdge(),
-                touchX = position.x,
-                touchY = position.y,
-            )
-        )
+        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() {
+    private suspend fun AwaitPointerEventScope.processGesture(dispatcher: PredictiveBackDispatcher) {
         while (true) {
             val change = awaitChange()
             val position = change.position
@@ -257,7 +261,7 @@ private class BackGestureHandler(
 
             val progress = getProgress(position = position)
 
-            backDispatcher.progressPredictiveBack(
+            dispatcher.progress(
                 BackEvent(
                     progress = progress,
                     swipeEdge = edge.toSwipeEdge(),
@@ -266,11 +270,12 @@ private class BackGestureHandler(
                 )
             )
 
+            println("Progress: $progress")
             if (!change.pressed) {
                 if (progress > progressConfirmationThreshold) {
-                    backDispatcher.back()
+                    dispatcher.finish()
                 } else {
-                    backDispatcher.cancelPredictiveBack()
+                    dispatcher.cancel()
                 }
 
                 return


Test patch:
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/backhandler/TestBackDispatcher.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/backhandler/TestBackDispatcher.kt
index 9793586d..1587dc9f 100644
--- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/backhandler/TestBackDispatcher.kt
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/backhandler/TestBackDispatcher.kt
@@ -3,19 +3,31 @@ package com.arkivanov.decompose.backhandler
 import com.arkivanov.essenty.backhandler.BackCallback
 import com.arkivanov.essenty.backhandler.BackDispatcher
 
-internal class TestBackDispatcher(
-    private val delegate: BackDispatcher = BackDispatcher()
-) : BackDispatcher by delegate {
+internal class TestBackDispatcher : BackDispatcher {
 
-    var size: Int = 0
+    private var set = emptySet<BackCallback>()
+    val size: Int get() = set.size
+
+    override val isEnabled: Boolean get() = set.any(BackCallback::isEnabled)
 
     override fun register(callback: BackCallback) {
-        delegate.register(callback)
-        size++
+        check(callback !in set) { "Callback is already registered" }
+
+        this.set += callback
     }
 
     override fun unregister(callback: BackCallback) {
-        delegate.unregister(callback)
-        size--
+        check(callback in set) { "Callback is not registered" }
+
+        this.set -= callback
+    }
+
+    override fun back(): Boolean {
+        set.lastOrNull(BackCallback::isEnabled)?.also {
+            it.onBack()
+            return true
+        }
+
+        return false
     }
 }