From 49907346186dfd61d0634ba3dae64d4d117a82fe Mon Sep 17 00:00:00 2001 From: Julio Cesar Bueno Cotta Date: Wed, 24 May 2023 07:59:04 +0000 Subject: [PATCH] FCAN-1841 | Feat (CORE) : Enable composables communication --- .../java/com/veepee/feature/a/AActivity.kt | 24 ++-- .../b/routes/FeatureBComposableNameMapper.kt | 10 +- .../feature_b/FeatureBComposableLink.kt | 10 +- ..._FCAN-1841_enable-compose-communication.md | 118 ++++++++++++++++++ .../java/com/veepee/vpcore/route/link/Link.kt | 5 + .../route/link/compose/ComposableLink.kt | 5 + .../route/link/compose/LinkRouterContainer.kt | 30 ++++- .../events/LinkRouterEventHandlerContainer.kt | 57 +++++++++ .../composable/ComposableLinkRouterTest.kt | 25 +++- .../feature/TestComposablesNameMapper.kt | 8 +- .../composable/route/TestComposableALink.kt | 5 +- .../composable/route/TestComposableBLink.kt | 7 +- 12 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 changelog/next/CORE_FCAN-1841_enable-compose-communication.md create mode 100644 library/src/main/java/com/veepee/vpcore/route/link/compose/events/LinkRouterEventHandlerContainer.kt diff --git a/Sample/feature_a/src/main/java/com/veepee/feature/a/AActivity.kt b/Sample/feature_a/src/main/java/com/veepee/feature/a/AActivity.kt index 237ad76..86af9cf 100644 --- a/Sample/feature_a/src/main/java/com/veepee/feature/a/AActivity.kt +++ b/Sample/feature_a/src/main/java/com/veepee/feature/a/AActivity.kt @@ -22,14 +22,18 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import com.veepee.routes.feature_a.FragmentALink import com.veepee.routes.feature_b.FeatureBComposableLink import com.veepee.routes.router -import com.veepee.vpcore.route.link.compose.ComposableFor import com.veepee.vpcore.route.link.compose.LinkRouterContainer +import com.veepee.vpcore.route.link.compose.ComposableFor class AActivity : AppCompatActivity(R.layout.a_activity) { @@ -54,21 +58,25 @@ class AActivity : AppCompatActivity(R.layout.a_activity) { @Composable private fun ARootComposition(modifier: Modifier = Modifier) { - + var text by remember { mutableStateOf("2 - AActivity sent this text to a Composable in B Module") } LinkRouterContainer(router = router) { Column { ComposableFor( - FeatureBComposableLink("1 - AActivity sent this text to a Composable in B Module"), - modifier + link = FeatureBComposableLink("1 - AActivity sent this text to a Composable in B Module"), + modifier = modifier .fillMaxWidth() .background(Color.Yellow) - ) + ) { + text = it.messageSize.toString() + } ComposableFor( - FeatureBComposableLink("2 - AActivity sent this text to a Composable in B Module"), - modifier + link = FeatureBComposableLink(text), + modifier = modifier .fillMaxWidth() .background(Color.Green) - ) + ) { + text = it.messageSize.toString() + } } } } diff --git a/Sample/feature_b/src/main/java/com/veepee/feature/b/routes/FeatureBComposableNameMapper.kt b/Sample/feature_b/src/main/java/com/veepee/feature/b/routes/FeatureBComposableNameMapper.kt index e40be21..b554061 100644 --- a/Sample/feature_b/src/main/java/com/veepee/feature/b/routes/FeatureBComposableNameMapper.kt +++ b/Sample/feature_b/src/main/java/com/veepee/feature/b/routes/FeatureBComposableNameMapper.kt @@ -16,13 +16,16 @@ package com.veepee.feature.b.routes +import androidx.compose.foundation.clickable import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.veepee.routes.feature_b.ComposableBNames +import com.veepee.routes.feature_b.FeatureBComposableEvent import com.veepee.routes.feature_b.FeatureBComposableLink import com.veepee.vpcore.route.link.compose.ComposableLink import com.veepee.vpcore.route.link.compose.ComposableNameMapper +import com.veepee.vpcore.route.link.compose.events.LocalLinkRouterEventHandler object FeatureBComposableNameMapper : ComposableNameMapper { @@ -33,8 +36,13 @@ object FeatureBComposableNameMapper : ComposableNameMapper { composableLink: ComposableLink, modifier: Modifier ) { + val handler = LocalLinkRouterEventHandler.current when (composableLink) { - is FeatureBComposableLink -> BasicText(composableLink.parameter.message, modifier) + is FeatureBComposableLink -> BasicText( + composableLink.parameter.message, + modifier.clickable { + handler.publish(FeatureBComposableEvent(composableLink.parameter.message.length)) + }) } } } diff --git a/Sample/routes/src/main/java/com/veepee/routes/feature_b/FeatureBComposableLink.kt b/Sample/routes/src/main/java/com/veepee/routes/feature_b/FeatureBComposableLink.kt index 783af44..6f9c717 100644 --- a/Sample/routes/src/main/java/com/veepee/routes/feature_b/FeatureBComposableLink.kt +++ b/Sample/routes/src/main/java/com/veepee/routes/feature_b/FeatureBComposableLink.kt @@ -15,7 +15,8 @@ */ package com.veepee.routes.feature_b -import com.veepee.vpcore.route.link.compose.ComposableLink +import com.veepee.vpcore.route.link.compose.ComposableEvent +import com.veepee.vpcore.route.link.compose.ComposableLinkWithEvent import com.veepee.vpcore.route.link.compose.ComposableName import com.veepee.vpcore.route.link.compose.ComposableParameter @@ -25,9 +26,12 @@ enum class ComposableBNames : ComposableName { data class FeatureBComposableLink( override val parameter: FeatureBComposableParameter -) : ComposableLink { +) : ComposableLinkWithEvent { override val composableName: ComposableBNames = ComposableBNames.ComposableB - constructor(message: String): this(FeatureBComposableParameter(message)) + + constructor(message: String) : this(FeatureBComposableParameter(message)) } data class FeatureBComposableParameter(val message: String) : ComposableParameter + +data class FeatureBComposableEvent(val messageSize: Int) : ComposableEvent diff --git a/changelog/next/CORE_FCAN-1841_enable-compose-communication.md b/changelog/next/CORE_FCAN-1841_enable-compose-communication.md new file mode 100644 index 0000000..04f6ec0 --- /dev/null +++ b/changelog/next/CORE_FCAN-1841_enable-compose-communication.md @@ -0,0 +1,118 @@ +--- +title: Enable composables communication +url: https://jira.vptech.eu/browse/FCAN-1841 +author: Julio Cesar Bueno Cotta +--- + + +Android components have ways of implicit communication, for instance +- Activities -> Activities -> OnActivityResult/Contracts +- Fragments -> Fragments -> Fragment Result API + +The started activity don't need to know which Activity called it, the Fragments sending results are not required to know who is listening for it's results. + + +But how about Composables -> ??? + + +Compose does not provide an out of the box solution to send implicit events to the callers. + + +### What did I do? + +This MR tries to enable that communication in a **somewhat** type safe way. + +Our approach avoids "lambda forwarding" by using a `LinkRouterEventHandler` that is instantiated when accessing a `LinkRouterEventHandlerContainer`. + +Example : +``` kotlin +sealed interface MyData : ComposableEvent { + data class MyData1(val foo: String) : MyData + data class MyData2(val foo: String) : MyData +} + +@Composable +fun MyScreen() { + LinkRouterEventHandlerContainer(onEvent = { + println(it) + }, + content = { + MyDataComposable() + } + ) +} + +@Composable +private fun MyDataComposable() { + MySecondLayerDataComposable() +} + +@Composable +private fun MySecondLayerDataComposable() { + val handler = LocalLinkRouterEventHandler.current + BasicText( + "hello world", + modifier = Modifier.clickable { handler.publish(MyData.MyData1("bar")) }) +} +``` + +Event though `MySecondLayerDataComposable` is a leaf composable and does not contain a "onClick : () -> Unit" lambda, the onClick event arrives to `onEvent` lambda in `MyScreen` composable. + +We say that this API is **somewhat** type safe because there is no guarantees that a `LinkRouterEventHandlerContainer` will be listening for the published type. + +For instance: +``` kotlin +@Composable +private fun MySecondLayerDataComposable() { + val handler = LocalLinkRouterEventHandler.current + BasicText( + "hello world", + modifier = Modifier.clickable { handler.publish("foobar") }) +} +``` +Would cause an error at **runtime** as there is no registered `LinkRouterEventHandlerContainer` that can handle Strings. + + +The new composable `ComposableFor` is meant to be called with `ComposableLinkWithEvent` implementations and offers a lambda with the expected event type that can be emitted + +``` kotlin +ComposableFor( + link = FeatureBComposableLink("1 - AActivity sent this text to a Composable in B Module"), + modifier = modifier + .fillMaxWidth() + .background(Color.Yellow) + ) { event -> + text = event.messageSize.toString() + } +``` + +The `FeatureBComposableLink` class is defined as +``` +class TestComposableBLink( + override val parameter: TestComposableBParameter +) : ComposableLinkWithEvent { + override val composableName: TestComposableName = TestComposableName.TestComposableB +} + +data class TestComposableBParameter(val message: String) : ComposableParameter + +data class TestComposableBLinkEvent(val foo: String) : ComposableEvent +``` + +Notice that it implements `ComposableLinkWithEvent`, not `ComposableLink` as we need the `ComposableEvent` type to ensure the correct match of types when routing this link. + + +### Notes: +- Is this fine? With this we open the gate to a new class of bugs in the project, runtime errors due to missing event handlers. +- Should `ComposableEvent` be renamed to `ComposableResult` ? I don't quite like the `Event` suffix. + +### QA: +1) what will happen if I call `handler.publish()` with a type that is not directly handled by the nearest EventHandler ? +The event will be forwarded to the next EventHandler until it reaches an EventHandler that can handle it or the topmost EventHandler where we are raising en exception and crashing. + +2) Can I use this to avoid the lambdas inside my feature composables? +Yes, but be aware that Google does not recommend this! We are using this in LinkRouter because we couldn't find a better way of sharing events in a generic way without rewriting the library. +As I stated above, there is no compile time check if an EventHandler is deployed in the compose hierarchy that can handle the type you want to publish. + +3) Can I use primitive types as Events? +Yes, but you **really** should think in using a sealed class or sealed interface for your public API as the caller can use a `when` to evaluate all possible states. diff --git a/library/src/main/java/com/veepee/vpcore/route/link/Link.kt b/library/src/main/java/com/veepee/vpcore/route/link/Link.kt index 5707a54..dc174fd 100644 --- a/library/src/main/java/com/veepee/vpcore/route/link/Link.kt +++ b/library/src/main/java/com/veepee/vpcore/route/link/Link.kt @@ -29,6 +29,11 @@ interface Link { * */ interface Parameter +/** + * Extra information passed from a destination to the caller. + * */ +interface Event + /** * Basic representation of a destination parameter to Activities and Fragments. * We require it to implement Parcelable as a way of having a standard serialization method diff --git a/library/src/main/java/com/veepee/vpcore/route/link/compose/ComposableLink.kt b/library/src/main/java/com/veepee/vpcore/route/link/compose/ComposableLink.kt index b6af59d..de5c2d6 100644 --- a/library/src/main/java/com/veepee/vpcore/route/link/compose/ComposableLink.kt +++ b/library/src/main/java/com/veepee/vpcore/route/link/compose/ComposableLink.kt @@ -15,9 +15,11 @@ */ package com.veepee.vpcore.route.link.compose +import com.veepee.vpcore.route.link.Event import com.veepee.vpcore.route.link.Link import com.veepee.vpcore.route.link.Parameter +interface ComposableLinkWithEvent : ComposableLink interface ComposableLink : Link { val composableName: T override val parameter: ComposableParameter? @@ -25,3 +27,6 @@ interface ComposableLink : Link { interface ComposableParameter : Parameter +interface ComposableEvent : Event + +object NoEvent : ComposableEvent diff --git a/library/src/main/java/com/veepee/vpcore/route/link/compose/LinkRouterContainer.kt b/library/src/main/java/com/veepee/vpcore/route/link/compose/LinkRouterContainer.kt index d1a51c0..98aa8d3 100644 --- a/library/src/main/java/com/veepee/vpcore/route/link/compose/LinkRouterContainer.kt +++ b/library/src/main/java/com/veepee/vpcore/route/link/compose/LinkRouterContainer.kt @@ -21,11 +21,16 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import com.veepee.vpcore.route.LinkRouter +import com.veepee.vpcore.route.link.compose.events.LinkRouterEventHandlerContainer val LocalLinkRouter = staticCompositionLocalOf { error("no local Router provided") } +val LocalComposableLinkRouter = staticCompositionLocalOf { + error("no local Router provided") +} + @Composable fun LinkRouterContainer( router: LinkRouter, @@ -33,6 +38,18 @@ fun LinkRouterContainer( ) { CompositionLocalProvider( LocalLinkRouter provides router, + ) { + ComposableLinkRouterContainer(router, content) + } +} + +@Composable +fun ComposableLinkRouterContainer( + router: ComposableLinkRouter, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalComposableLinkRouter provides router ) { content() } @@ -40,5 +57,16 @@ fun LinkRouterContainer( @Composable fun ComposableFor(link: ComposableLink, modifier: Modifier = Modifier) { - LocalLinkRouter.current.ComposeFor(composableLink = link, modifier) + LocalComposableLinkRouter.current.ComposeFor(composableLink = link, modifier) +} + +@Composable +inline fun ComposableFor( + link: ComposableLinkWithEvent, + modifier: Modifier = Modifier, + noinline onEvent: (Event) -> Unit +) { + LinkRouterEventHandlerContainer(onEvent = onEvent) { + ComposableFor(link = link, modifier = modifier) + } } diff --git a/library/src/main/java/com/veepee/vpcore/route/link/compose/events/LinkRouterEventHandlerContainer.kt b/library/src/main/java/com/veepee/vpcore/route/link/compose/events/LinkRouterEventHandlerContainer.kt new file mode 100644 index 0000000..6626449 --- /dev/null +++ b/library/src/main/java/com/veepee/vpcore/route/link/compose/events/LinkRouterEventHandlerContainer.kt @@ -0,0 +1,57 @@ +package com.veepee.vpcore.route.link.compose.events + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import com.veepee.vpcore.route.link.compose.ComposableEvent + +@Composable +inline fun LinkRouterEventHandlerContainer( + current: LinkRouterEventHandler = LocalLinkRouterEventHandler.current, + noinline onEvent: (Event) -> Unit, + crossinline content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalLinkRouterEventHandler provides LinkRouterEventHandler( + parent = current, + isAssignableFrom = { event -> + Event::class.java.isAssignableFrom(event::class.java) + }, + onEvent = onEvent + ) + ) { + content() + } +} + +class LinkRouterEventHandler constructor( + private val parent: LinkRouterEventHandler? = null, + private val isAssignableFrom: (event: Any) -> Boolean, + private val onEvent: (Event) -> Unit +) { + @Suppress("UNCHECKED_CAST") + fun publish(event: ComposableEvent) { + if (isAssignableFrom(event)) { + onEvent(event as Event) + } else if (parent == null) { + throw NoParentEventHandlerException() + } else { + parent.publish(event) + } + } +} + +class NoParentEventHandlerException : RuntimeException("No parent LocalEventHandler found!") +class NoEventHandlerException(event: ComposableEvent) : + RuntimeException("Your event \"$event\" reached the topmost EventHandler one in the compose hierarchy without a handler!") + +val LocalLinkRouterEventHandler: ProvidableCompositionLocal> = + compositionLocalOf { + LinkRouterEventHandler( + isAssignableFrom = { true }, + onEvent = { event -> + throw NoEventHandlerException(event) + } + ) + } diff --git a/library/src/test/java/com/veepee/vpcore/route/composable/ComposableLinkRouterTest.kt b/library/src/test/java/com/veepee/vpcore/route/composable/ComposableLinkRouterTest.kt index cf81207..ddcac1f 100644 --- a/library/src/test/java/com/veepee/vpcore/route/composable/ComposableLinkRouterTest.kt +++ b/library/src/test/java/com/veepee/vpcore/route/composable/ComposableLinkRouterTest.kt @@ -18,11 +18,14 @@ package com.veepee.vpcore.route.composable import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import com.veepee.vpcore.route.composable.feature.TestComposablesNameMapper import com.veepee.vpcore.route.composable.route.TestComposableALink import com.veepee.vpcore.route.composable.route.TestComposableBLink import com.veepee.vpcore.route.composable.route.TestComposableBParameter +import com.veepee.vpcore.route.link.compose.ComposableFor import com.veepee.vpcore.route.link.compose.ComposableLink +import com.veepee.vpcore.route.link.compose.ComposableLinkRouterContainer import com.veepee.vpcore.route.link.compose.ComposableLinkRouterImpl import com.veepee.vpcore.route.link.compose.ComposableName import com.veepee.vpcore.route.link.compose.ComposableNameMapper @@ -74,7 +77,6 @@ class ComposableLinkRouterTest { @Test fun `should route and display text given to composable`() { val router = ComposableLinkRouterImpl(mappers, ChainFactoryImpl(emptyList())) - composeTestRule.setContent { router.ComposeFor(TestComposableBLink(TestComposableBParameter("message"))) } @@ -84,6 +86,27 @@ class ComposableLinkRouterTest { .assertIsDisplayed() } + @Test + fun `should route and consume result`() { + val router = ComposableLinkRouterImpl(mappers, ChainFactoryImpl(emptyList())) + var message = "" + composeTestRule.setContent { + ComposableLinkRouterContainer(router) { + ComposableFor( + TestComposableBLink(TestComposableBParameter("message")) + ) { event -> + message = event.foo + } + } + } + + composeTestRule + .onNodeWithText("message") + .performClick() + + Assert.assertEquals(message, "bar") + } + @Test fun `should intercept route and display other composable`() { val router = ComposableLinkRouterImpl( diff --git a/library/src/test/java/com/veepee/vpcore/route/composable/feature/TestComposablesNameMapper.kt b/library/src/test/java/com/veepee/vpcore/route/composable/feature/TestComposablesNameMapper.kt index 32c0d3d..b44ce34 100644 --- a/library/src/test/java/com/veepee/vpcore/route/composable/feature/TestComposablesNameMapper.kt +++ b/library/src/test/java/com/veepee/vpcore/route/composable/feature/TestComposablesNameMapper.kt @@ -15,24 +15,30 @@ */ package com.veepee.vpcore.route.composable.feature +import androidx.compose.foundation.clickable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.veepee.vpcore.route.composable.route.TestComposableALink import com.veepee.vpcore.route.composable.route.TestComposableBLink +import com.veepee.vpcore.route.composable.route.TestComposableBLinkEvent import com.veepee.vpcore.route.composable.route.TestComposableName import com.veepee.vpcore.route.link.compose.ComposableLink import com.veepee.vpcore.route.link.compose.ComposableNameMapper +import com.veepee.vpcore.route.link.compose.events.LocalLinkRouterEventHandler object TestComposablesNameMapper : ComposableNameMapper { override val supportedNames: Array = TestComposableName.values() @Composable override fun Map(composableLink: ComposableLink, modifier: Modifier) { + val handler = LocalLinkRouterEventHandler.current when (composableLink) { is TestComposableALink -> TestComposableA(modifier = modifier) is TestComposableBLink -> TestComposableB( composableLink.parameter.message, - modifier = modifier + modifier = modifier.clickable { + handler.publish(TestComposableBLinkEvent("bar")) + } ) } } diff --git a/library/src/test/java/com/veepee/vpcore/route/composable/route/TestComposableALink.kt b/library/src/test/java/com/veepee/vpcore/route/composable/route/TestComposableALink.kt index 0206393..15356cb 100644 --- a/library/src/test/java/com/veepee/vpcore/route/composable/route/TestComposableALink.kt +++ b/library/src/test/java/com/veepee/vpcore/route/composable/route/TestComposableALink.kt @@ -15,10 +15,11 @@ */ package com.veepee.vpcore.route.composable.route -import com.veepee.vpcore.route.link.compose.ComposableLink +import com.veepee.vpcore.route.link.compose.ComposableLinkWithEvent import com.veepee.vpcore.route.link.compose.ComposableParameter +import com.veepee.vpcore.route.link.compose.NoEvent -class TestComposableALink : ComposableLink { +class TestComposableALink : ComposableLinkWithEvent { override val composableName: TestComposableName = TestComposableName.TestComposableA override val parameter: ComposableParameter? = null } diff --git a/library/src/test/java/com/veepee/vpcore/route/composable/route/TestComposableBLink.kt b/library/src/test/java/com/veepee/vpcore/route/composable/route/TestComposableBLink.kt index 3b549ad..9bfdd89 100644 --- a/library/src/test/java/com/veepee/vpcore/route/composable/route/TestComposableBLink.kt +++ b/library/src/test/java/com/veepee/vpcore/route/composable/route/TestComposableBLink.kt @@ -15,13 +15,16 @@ */ package com.veepee.vpcore.route.composable.route -import com.veepee.vpcore.route.link.compose.ComposableLink +import com.veepee.vpcore.route.link.compose.ComposableEvent +import com.veepee.vpcore.route.link.compose.ComposableLinkWithEvent import com.veepee.vpcore.route.link.compose.ComposableParameter class TestComposableBLink( override val parameter: TestComposableBParameter -) : ComposableLink { +) : ComposableLinkWithEvent { override val composableName: TestComposableName = TestComposableName.TestComposableB } data class TestComposableBParameter(val message: String) : ComposableParameter + +data class TestComposableBLinkEvent(val foo: String) : ComposableEvent