Skip to content

Commit

Permalink
Merge branch 'CORE/feat/FCAN-1841_enable-compose-communication' into …
Browse files Browse the repository at this point in the history
…'main'

FCAN-1841 | Feat (CORE) : Enable composables communication

See merge request veepee/offerdiscovery/products/front-mobile/android/link-router!24
  • Loading branch information
Julio Cesar Bueno Cotta committed May 24, 2023
2 parents df5e97f + 4990734 commit 224a776
Show file tree
Hide file tree
Showing 12 changed files with 285 additions and 19 deletions.
24 changes: 16 additions & 8 deletions Sample/feature_a/src/main/java/com/veepee/feature/a/AActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand All @@ -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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComposableBNames> {

Expand All @@ -33,8 +36,13 @@ object FeatureBComposableNameMapper : ComposableNameMapper<ComposableBNames> {
composableLink: ComposableLink<ComposableBNames>,
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))
})
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,9 +26,12 @@ enum class ComposableBNames : ComposableName {

data class FeatureBComposableLink(
override val parameter: FeatureBComposableParameter
) : ComposableLink<ComposableBNames> {
) : ComposableLinkWithEvent<ComposableBNames, FeatureBComposableEvent> {
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
118 changes: 118 additions & 0 deletions changelog/next/CORE_FCAN-1841_enable-compose-communication.md
Original file line number Diff line number Diff line change
@@ -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<MyData>(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<TestComposableName, TestComposableBLinkEvent> {
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.
5 changes: 5 additions & 0 deletions library/src/main/java/com/veepee/vpcore/route/link/Link.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@
*/
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<out T : ComposableName, R : ComposableEvent> : ComposableLink<T>
interface ComposableLink<out T : ComposableName> : Link {
val composableName: T
override val parameter: ComposableParameter?
}

interface ComposableParameter : Parameter

interface ComposableEvent : Event

object NoEvent : ComposableEvent
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,52 @@ 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<LinkRouter> {
error("no local Router provided")
}

val LocalComposableLinkRouter = staticCompositionLocalOf<ComposableLinkRouter> {
error("no local Router provided")
}

@Composable
fun LinkRouterContainer(
router: LinkRouter,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalLinkRouter provides router,
) {
ComposableLinkRouterContainer(router, content)
}
}

@Composable
fun ComposableLinkRouterContainer(
router: ComposableLinkRouter,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalComposableLinkRouter provides router
) {
content()
}
}

@Composable
fun ComposableFor(link: ComposableLink<ComposableName>, modifier: Modifier = Modifier) {
LocalLinkRouter.current.ComposeFor(composableLink = link, modifier)
LocalComposableLinkRouter.current.ComposeFor(composableLink = link, modifier)
}

@Composable
inline fun <reified Event : ComposableEvent> ComposableFor(
link: ComposableLinkWithEvent<ComposableName, Event>,
modifier: Modifier = Modifier,
noinline onEvent: (Event) -> Unit
) {
LinkRouterEventHandlerContainer(onEvent = onEvent) {
ComposableFor(link = link, modifier = modifier)
}
}
Original file line number Diff line number Diff line change
@@ -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 <reified Event : ComposableEvent> LinkRouterEventHandlerContainer(
current: LinkRouterEventHandler<out ComposableEvent> = 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<Event : ComposableEvent> constructor(
private val parent: LinkRouterEventHandler<out ComposableEvent>? = 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<LinkRouterEventHandler<out ComposableEvent>> =
compositionLocalOf {
LinkRouterEventHandler(
isAssignableFrom = { true },
onEvent = { event ->
throw NoEventHandlerException(event)
}
)
}
Loading

0 comments on commit 224a776

Please sign in to comment.