diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20c4086e6..63f954d39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## Pending changes
- [#268](https://github.com/bumble-tech/appyx/pull/268) – **Fixed**: `PermanentChild` now does not crash in UI tests with `ComposeTestRule`.
+- [#268](https://github.com/bumble-tech/appyx/pull/276) – **Fixed**: Back press handlers order is fixed for RIBs-Appyx integration.
- [#272](https://github.com/bumble-tech/appyx/pull/272) – **Changed**: `attachWorkflow` renamed to `attachChild`. `executeWorkflow` renamed to `executeAction`.
- [#272](https://github.com/bumble-tech/appyx/pull/272) – **Added**: `NodeReadyObserver` plugin to observe when the `Node` is ready
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e46fe27d3..e346e0a80 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -54,6 +54,7 @@ mvicore-android = { module = "com.github.badoo.mvicore:mvicore-android", version
mvicore-binder = { module = "com.github.badoo.mvicore:binder", version.ref = "mvicore" }
ribs-base = { module = "com.github.badoo.RIBs:rib-base", version.ref = "ribs" }
ribs-base-test = { module = "com.github.badoo.RIBs:rib-base-test", version.ref = "ribs" }
+ribs-base-test-activity = { module = "com.github.badoo.RIBs:rib-base-test-activity", version.ref = "ribs" }
ribs-base-test-rx2 = { module = "com.github.badoo.RIBs:rib-base-test-rx2", version.ref = "ribs" }
ribs-compose = { module = "com.github.badoo.RIBs:rib-compose", version.ref = "ribs" }
ribs-rx = { module = "com.github.badoo.RIBs:rib-rx2", version.ref = "ribs" }
diff --git a/libraries/interop-ribs/build.gradle.kts b/libraries/interop-ribs/build.gradle.kts
index cb49fc9e2..26b8c3404 100644
--- a/libraries/interop-ribs/build.gradle.kts
+++ b/libraries/interop-ribs/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
id("com.android.library")
id("kotlin-android")
+ id("kotlin-parcelize")
id("appyx-publish-android")
id("appyx-lint")
id("appyx-detekt")
@@ -30,6 +31,14 @@ dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.lifecycle.java8)
+ implementation(libs.androidx.activity.compose)
implementation(libs.compose.ui.ui)
implementation(libs.ribs.compose)
+
+ androidTestImplementation(libs.androidx.activity.compose)
+ androidTestImplementation(libs.androidx.test.espresso.core)
+ androidTestImplementation(libs.androidx.test.junit)
+ androidTestImplementation(libs.compose.foundation.layout)
+ androidTestImplementation(libs.compose.ui.test.junit4)
+ androidTestImplementation(libs.ribs.base.test.activity)
}
diff --git a/libraries/interop-ribs/src/androidTest/AndroidManifest.xml b/libraries/interop-ribs/src/androidTest/AndroidManifest.xml
new file mode 100644
index 000000000..875944729
--- /dev/null
+++ b/libraries/interop-ribs/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/AppyxRibsInteropActivity.kt b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/AppyxRibsInteropActivity.kt
new file mode 100644
index 000000000..9e334a717
--- /dev/null
+++ b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/AppyxRibsInteropActivity.kt
@@ -0,0 +1,20 @@
+package com.bumble.appyx.interop.ribs
+
+import android.os.Bundle
+import android.view.ViewGroup
+import com.badoo.ribs.core.Rib
+import com.badoo.ribs.core.modality.BuildContext
+
+class AppyxRibsInteropActivity : InteropActivity() {
+
+ lateinit var ribsNode: RibsNode
+
+ override val rootViewGroup: ViewGroup
+ get() = findViewById(android.R.id.content)
+
+ override fun createRib(savedInstanceState: Bundle?): Rib =
+ RibsNodeBuilder()
+ .build(BuildContext.root(savedInstanceState), appyxIntegrationPoint)
+ .also { ribsNode = it }
+
+}
diff --git a/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/InteropNodeImplTest.kt b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/InteropNodeImplTest.kt
new file mode 100644
index 000000000..ae855bc8e
--- /dev/null
+++ b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/InteropNodeImplTest.kt
@@ -0,0 +1,35 @@
+package com.bumble.appyx.interop.ribs
+
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.test.espresso.Espresso
+import org.junit.Rule
+import org.junit.Test
+
+class InteropNodeImplTest {
+
+ @get:Rule
+ val rule = createAndroidComposeRule(AppyxRibsInteropActivity::class.java)
+
+ @Test
+ fun appyx_back_press_is_handled_before_rib_parent() {
+ // push a new child into the backstack
+ var newChild = ""
+ var oldChild = ""
+ rule.activityRule.scenario.onActivity {
+ oldChild = it.ribsNode.current()
+ newChild = it.ribsNode.push()
+ }
+
+ Espresso.pressBack()
+
+ // the back press is swollen by the child node, newChild is still displayed
+ rule.onNodeWithTag(newChild).assertExists()
+
+ Espresso.pressBack()
+
+ // the back press is handled by RIBs now and the old child is displayed
+ rule.onNodeWithTag(oldChild).assertExists()
+ }
+
+}
diff --git a/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/RibsNode.kt b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/RibsNode.kt
new file mode 100644
index 000000000..b16a61fd1
--- /dev/null
+++ b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/RibsNode.kt
@@ -0,0 +1,112 @@
+package com.bumble.appyx.interop.ribs
+
+import android.os.Parcelable
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.lifecycle.Lifecycle
+import com.badoo.ribs.builder.Builder
+import com.badoo.ribs.core.Node
+import com.badoo.ribs.core.modality.BuildParams
+import com.badoo.ribs.core.plugin.Plugin
+import com.badoo.ribs.core.view.AndroidRibView2
+import com.badoo.ribs.core.view.ViewFactory
+import com.badoo.ribs.routing.Routing
+import com.badoo.ribs.routing.resolution.ChildResolution
+import com.badoo.ribs.routing.resolution.Resolution
+import com.badoo.ribs.routing.router.Router
+import com.badoo.ribs.routing.source.backstack.BackStack
+import com.badoo.ribs.routing.source.backstack.operation.push
+import com.bumble.appyx.core.integrationpoint.IntegrationPoint
+import com.bumble.appyx.core.modality.BuildContext
+import kotlinx.parcelize.Parcelize
+import java.util.UUID
+
+class RibsNode(
+ buildParams: BuildParams<*>,
+ private val backStack: BackStack,
+ plugins: List,
+) : Node(
+ buildParams = buildParams,
+ viewFactory = View.Factory(),
+ plugins = plugins,
+) {
+
+ fun current(): String =
+ backStack.activeConfiguration.id
+
+ fun push(): String {
+ val id = UUID.randomUUID().toString()
+ backStack.push(RibsNodeRouter.Configuration(id))
+ return id
+ }
+
+ class View(
+ androidView: ViewGroup,
+ lifecycle: Lifecycle,
+ ) : AndroidRibView2(
+ androidView,
+ lifecycle
+ ) {
+ class Factory : ViewFactory {
+ override fun invoke(context: ViewFactory.Context): View =
+ View(FrameLayout(context.parent.context), context.lifecycle)
+ }
+ }
+}
+
+class RibsNodeBuilder : Builder() {
+ override fun build(buildParams: BuildParams): RibsNode {
+ val backStack = BackStack(
+ RibsNodeRouter.Configuration(UUID.randomUUID().toString()),
+ buildParams
+ )
+ val router = RibsNodeRouter(buildParams, buildParams.payload, backStack)
+ return RibsNode(buildParams, backStack, listOf(router))
+ }
+}
+
+class RibsNodeRouter(
+ buildParams: BuildParams<*>,
+ private val integrationPoint: IntegrationPoint,
+ backStack: BackStack,
+) : Router(
+ buildParams = buildParams,
+ routingSource = backStack
+) {
+ @Parcelize
+ data class Configuration(val id: String) : Parcelable
+
+ override fun resolve(routing: Routing): Resolution =
+ ChildResolution.child {
+ InteropBuilder(
+ nodeFactory = { buildContext -> AppyxNode(buildContext, routing.configuration.id) },
+ integrationPoint = integrationPoint,
+ ).build(it)
+ }
+}
+
+class AppyxNode(
+ buildContext: BuildContext,
+ private val s: String,
+) : com.bumble.appyx.core.node.Node(
+ buildContext,
+) {
+ var shouldInterceptBackPress by mutableStateOf(true)
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Box(modifier = modifier.testTag(s)) {
+ BackHandler(shouldInterceptBackPress) {
+ shouldInterceptBackPress = false
+ }
+ }
+ }
+}
diff --git a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBackPressHandler.kt b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBackPressHandler.kt
new file mode 100644
index 000000000..5dde59127
--- /dev/null
+++ b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBackPressHandler.kt
@@ -0,0 +1,45 @@
+package com.bumble.appyx.interop.ribs
+
+import androidx.activity.OnBackPressedDispatcher
+import androidx.activity.OnBackPressedDispatcherOwner
+import androidx.lifecycle.Lifecycle
+import com.badoo.ribs.core.plugin.BackPressHandler
+import com.badoo.ribs.core.plugin.NodeLifecycleAware
+
+/**
+ * When we put Appyx into RIBs, we have the following invocation order of back handlers:
+ * 1. All active RIBs handlers using RIBs back press API.
+ * 2. All active Appyx handlers using AndroidX back press API.
+ *
+ * The reason is RIBs integration point implementation
+ * where `super.onBackPressed()` is invoked after `Node.handleBackPress()`.
+ *
+ * Let's collect all Appyx back press handlers into a separate [OnBackPressedDispatcher]
+ * and use it for `handleBackPress()`.
+ */
+internal class InteropBackPressHandler :
+ BackPressHandler,
+ OnBackPressedDispatcherOwner,
+ NodeLifecycleAware {
+
+ private val dispatcher = OnBackPressedDispatcher()
+ private lateinit var lifecycle: Lifecycle
+
+ override fun onCreate(nodeLifecycle: Lifecycle) {
+ lifecycle = nodeLifecycle
+ }
+
+ override fun handleBackPress(): Boolean =
+ if (dispatcher.hasEnabledCallbacks()) {
+ dispatcher.onBackPressed()
+ true
+ } else {
+ false
+ }
+
+ override fun getLifecycle(): Lifecycle = lifecycle
+
+ override fun getOnBackPressedDispatcher(): OnBackPressedDispatcher =
+ dispatcher
+
+}
diff --git a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBuilder.kt b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBuilder.kt
index 73ad307e4..954da28d7 100644
--- a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBuilder.kt
+++ b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBuilder.kt
@@ -2,12 +2,12 @@ package com.bumble.appyx.interop.ribs
import com.badoo.ribs.builder.SimpleBuilder
import com.badoo.ribs.core.modality.BuildParams
-import com.bumble.appyx.interop.ribs.InteropNodeImpl.Companion.InteropNodeKey
import com.bumble.appyx.core.integration.NodeFactory
import com.bumble.appyx.core.integrationpoint.IntegrationPoint
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.build
+import com.bumble.appyx.interop.ribs.InteropNodeImpl.Companion.InteropNodeKey
class InteropBuilder(
private val nodeFactory: NodeFactory,
diff --git a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropNode.kt b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropNode.kt
index 9d7a85d0a..52e20ec8c 100644
--- a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropNode.kt
+++ b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropNode.kt
@@ -1,6 +1,7 @@
package com.bumble.appyx.interop.ribs
import android.os.Bundle
+import androidx.activity.OnBackPressedDispatcherOwner
import androidx.core.os.bundleOf
import androidx.lifecycle.LifecycleEventObserver
import com.badoo.ribs.core.Rib
@@ -22,6 +23,7 @@ interface InteropNode : Rib {
internal class InteropNodeImpl(
buildParams: BuildParams<*>,
override val appyxNode: N,
+ private val backPressHandler: InteropBackPressHandler = InteropBackPressHandler(),
) : com.badoo.ribs.core.Node(
buildParams = buildParams,
viewFactory = buildParams.getOrDefault(
@@ -29,10 +31,13 @@ internal class InteropNodeImpl(
viewFactory = Factory().invoke(
object : InteropView.Dependency {
override val appyxNode: N = appyxNode
+ override val onBackPressedDispatcherOwner: OnBackPressedDispatcherOwner =
+ backPressHandler
}
)
),
- ).viewFactory
+ ).viewFactory,
+ plugins = listOf(backPressHandler),
), InteropNode {
private val observer = LifecycleEventObserver { source, _ ->
diff --git a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropView.kt b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropView.kt
index 3bf226834..115b2a6f4 100644
--- a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropView.kt
+++ b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropView.kt
@@ -1,7 +1,10 @@
package com.bumble.appyx.interop.ribs
import android.content.Context
+import androidx.activity.OnBackPressedDispatcherOwner
+import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.lifecycle.Lifecycle
import com.badoo.ribs.compose.ComposeRibView
import com.badoo.ribs.compose.ComposeView
@@ -15,6 +18,7 @@ interface InteropView : RibView {
interface Dependency {
val appyxNode: N
+ val onBackPressedDispatcherOwner: OnBackPressedDispatcherOwner
}
}
@@ -22,11 +26,16 @@ internal class InteropViewImpl private constructor(
override val context: Context,
lifecycle: Lifecycle,
private val appyxNode: Node,
+ private val onBackPressedDispatcherOwner: OnBackPressedDispatcherOwner,
) : InteropView, ComposeRibView(context, lifecycle) {
override val composable: ComposeView
get() = @Composable {
- appyxNode.Compose()
+ CompositionLocalProvider(
+ LocalOnBackPressedDispatcherOwner provides onBackPressedDispatcherOwner,
+ ) {
+ appyxNode.Compose()
+ }
}
class Factory : ViewFactoryBuilder, InteropView> {
@@ -36,6 +45,7 @@ internal class InteropViewImpl private constructor(
context = it.parent.context,
lifecycle = it.lifecycle,
appyxNode = deps.appyxNode,
+ onBackPressedDispatcherOwner = deps.onBackPressedDispatcherOwner,
)
}
}