Skip to content

Commit

Permalink
Merge pull request #276 from CherryPerry/ribs-appyx-back-fix
Browse files Browse the repository at this point in the history
Fix back press invocation order in RIBs-Appyx integration
  • Loading branch information
CherryPerry authored Nov 21, 2022
2 parents 7e1c70a + c388446 commit 6fd7d39
Show file tree
Hide file tree
Showing 11 changed files with 253 additions and 3 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
9 changes: 9 additions & 0 deletions libraries/interop-ribs/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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)
}
12 changes: 12 additions & 0 deletions libraries/interop-ribs/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>

<activity
android:name="com.bumble.appyx.interop.ribs.AppyxRibsInteropActivity"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />

</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -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 }

}
Original file line number Diff line number Diff line change
@@ -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()
}

}
Original file line number Diff line number Diff line change
@@ -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<RibsNodeRouter.Configuration>,
plugins: List<Plugin>,
) : Node<RibsNode.View>(
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<View> {
override fun invoke(context: ViewFactory.Context): View =
View(FrameLayout(context.parent.context), context.lifecycle)
}
}
}

class RibsNodeBuilder : Builder<IntegrationPoint, RibsNode>() {
override fun build(buildParams: BuildParams<IntegrationPoint>): 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<Configuration>,
) : Router<RibsNodeRouter.Configuration>(
buildParams = buildParams,
routingSource = backStack
) {
@Parcelize
data class Configuration(val id: String) : Parcelable

override fun resolve(routing: Routing<Configuration>): 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
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Activity>.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

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<N : Node>(
private val nodeFactory: NodeFactory<N>,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,17 +23,21 @@ interface InteropNode<N : Node> : Rib {
internal class InteropNodeImpl<N : Node>(
buildParams: BuildParams<*>,
override val appyxNode: N,
private val backPressHandler: InteropBackPressHandler = InteropBackPressHandler(),
) : com.badoo.ribs.core.Node<InteropView>(
buildParams = buildParams,
viewFactory = buildParams.getOrDefault(
Customisation(
viewFactory = Factory<N>().invoke(
object : InteropView.Dependency<N> {
override val appyxNode: N = appyxNode
override val onBackPressedDispatcherOwner: OnBackPressedDispatcherOwner =
backPressHandler
}
)
),
).viewFactory
).viewFactory,
plugins = listOf(backPressHandler),
), InteropNode<N> {

private val observer = LifecycleEventObserver { source, _ ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,18 +18,24 @@ interface InteropView : RibView {

interface Dependency<N : Node> {
val appyxNode: N
val onBackPressedDispatcherOwner: OnBackPressedDispatcherOwner
}
}

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<N : Node> : ViewFactoryBuilder<Dependency<N>, InteropView> {
Expand All @@ -36,6 +45,7 @@ internal class InteropViewImpl private constructor(
context = it.parent.context,
lifecycle = it.lifecycle,
appyxNode = deps.appyxNode,
onBackPressedDispatcherOwner = deps.onBackPressedDispatcherOwner,
)
}
}
Expand Down

0 comments on commit 6fd7d39

Please sign in to comment.