From 13968d1ec25803b4d5509d1e6a88732fde1269de Mon Sep 17 00:00:00 2001 From: Ray Ryan Date: Mon, 23 Oct 2023 10:18:27 -0700 Subject: [PATCH] BREAKING: Replaces `ComposeScreenViewFactory` with `ScreenComposableFactory` This commit takes advantage of the new `ViewRegistry.Key`, which allows renderings to be bound to multiple UI factories, to fix a long standing problem where wrapper screens -- things like `NamedScreen` and `EnvironmentScreen` -- used in a Compose context would cause needless calls to `@Composeable fun AndroidView()` and `ComposeView()`. For example, consider this rendering: ``` BodyAndOverlaysScreen( body = SomeComposeScreen( EnvironmentScreen( SomeOtherComposeScreen ) ) ) ``` Before this change, that would create a View hierarchy something like this: ``` BodyAndOverlaysContainer : FrameLayout { mChildren[0] = ComposeView { // compose land SomeComposeScreen.Content { AndroidView { ComposeView { // nested compose land SomeOtherComposeScreen.Content() ``` Now it will look this way: ``` BodyAndOverlaysContainer : FrameLayout { mChildren[0] = ComposeView { // compose land SomeComposeScreen.Content { SomeOtherComposeScreen.Content() ``` `ScreenComposableFactory` replaces `ComposeScreenViewFactory`, and `ComposeScreen` no longer extends `AndroidScreen`. Compose support is now a first class citizen, instead of a hack bolted on to View support. Unfortunately, `ViewEnvironment.withComposeInteropSupport()` (see below) now must be called near the root of a Workflow app to enable the seamless Compose > Classic > Compose handling that used to be built in to `WorkflowRendering` and `ComposeScreenViewFactory`. This means that call is required for Compose support for built in rendering types like `BodyAndOverlaysScreen` and `BackStackScreen`, which so far are backed only by classic View implementations. Other introductions, changes: - `Screen.toComposableFactory()`, used by `WorkflowRendering()` in the same way that `WorkflowViewStub` uses `Screen.toViewFactory()` - `ScreenComposableFactoryFinder`, a `ViewEnvironment`-based strategy object used by `Screen.toComposableFactory()` the same way that `Screen.toViewFactory()` uses `ScreenViewFactoryFinder`. The default implementation provides Compose bindings for `NamedScreen` and `EnvironmentScreen`, fixing #546. - `ScreenViewFactoryFinder.getViewFactoryForRendering()` can now return `null`. A `requireViewFactoryForRendering()` extension is introduced for use when `null` is not acceptable. - `ViewEnvironment.withComposeInteropSupport()`, which wraps the found `ScreenComposableFactoryFinder` and `ScreenViewFactoryFinder` with implementations that allow Compose contexts to handle renderings bound only to `ScreenViewFactory`, and classic contexts to handle renderings bound only to `ScreenComposableFactory`. Replaces the logic that used to be in the private `ScreenViewFactory.asComposeViewFactory()` extension in `WorkflowRendering()`. - `Screen.Preview()` is introduced. The existing `Preview()` extension functions were tied to `ScreenViewFactory`, making them much less useful. It is still the case that previews work for non-Compose UI code just fine. Which is pretty cool, really. Fixes #546 --- .../sample/compose/hellocompose/App.kt | 7 +- .../compose/hellocompose/HelloBinding.kt | 22 -- .../hellocompose/HelloComposeScreen.kt | 40 +++ ...lloWorkflow.kt => HelloComposeWorkflow.kt} | 19 +- .../hellocomposebinding/HelloBinding.kt | 4 +- .../HelloBindingActivity.kt | 11 +- .../HelloComposeWorkflowActivity.kt | 8 +- .../InlineRenderingActivity.kt | 8 +- .../InlineRenderingWorkflow.kt | 6 +- .../sample/compose/launcher/Samples.kt | 2 +- .../compose/nestedrenderings/LegacyRunner.kt | 10 +- .../NestedRenderingsActivity.kt | 16 +- .../nestedrenderings/RecursiveViewFactory.kt | 4 +- .../nestedrenderings/RecursiveWorkflow.kt | 12 +- .../sample/compose/preview/PreviewActivity.kt | 54 +-- .../compose/textinput/TextInputViewFactory.kt | 4 +- workflow-ui/compose-tooling/README.md | 3 - .../compose-tooling/api/compose-tooling.api | 8 +- .../compose/tooling/PreviewViewFactoryTest.kt | 10 +- .../compose/tooling/PlaceholderViewFactory.kt | 10 +- .../compose/tooling/PreviewViewEnvironment.kt | 52 ++- .../workflow1/ui/compose/tooling/Previews.kt | 111 ++++++ .../ui/compose/tooling/ViewFactories.kt | 75 ---- workflow-ui/compose/README.md | 337 ++++++------------ workflow-ui/compose/api/compose.api | 67 +++- ...Test.kt => ScreenComposableFactoryTest.kt} | 20 +- .../ui/compose/WorkflowRenderingTest.kt | 162 +++++---- .../workflow1/ui/compose/ComposeScreen.kt | 83 +++-- .../ui/compose/ComposeScreenViewFactory.kt | 144 -------- .../workflow1/ui/compose/CompositionRoot.kt | 45 +-- .../ui/compose/ScreenComposableFactory.kt | 233 ++++++++++++ .../compose/ScreenComposableFactoryFinder.kt | 69 ++++ .../ViewEnvironmentWithComposeSupport.kt | 57 +++ .../workflow1/ui/compose/WorkflowRendering.kt | 104 +----- workflow-ui/core-android/api/core-android.api | 4 + .../workflow1/ui/ScreenViewFactory.kt | 8 +- .../workflow1/ui/ScreenViewFactoryFinder.kt | 28 +- workflow-ui/core-common/api/core-common.api | 1 + .../workflow1/ui/EnvironmentScreen.kt | 9 + .../workflow1/ui/EnvironmentScreenTest.kt | 41 ++- 40 files changed, 1076 insertions(+), 832 deletions(-) delete mode 100644 samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloBinding.kt create mode 100644 samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt rename samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/{HelloWorkflow.kt => HelloComposeWorkflow.kt} (59%) delete mode 100644 workflow-ui/compose-tooling/README.md create mode 100644 workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/Previews.kt delete mode 100644 workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/ViewFactories.kt rename workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/{ComposeScreenViewFactoryTest.kt => ScreenComposableFactoryTest.kt} (85%) delete mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreenViewFactory.kt create mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt create mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt create mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/App.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/App.kt index affb765fdd..9a47062583 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/App.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/App.kt @@ -14,17 +14,16 @@ import androidx.compose.ui.unit.dp import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.WorkflowRendering import com.squareup.workflow1.ui.compose.renderAsState -import com.squareup.workflow1.ui.plus +import com.squareup.workflow1.ui.compose.withComposeInteropSupport -private val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(HelloBinding) +private val viewEnvironment = ViewEnvironment.EMPTY.withComposeInteropSupport() @Composable fun App() { MaterialTheme { - val rendering by HelloWorkflow.renderAsState( + val rendering by HelloComposeWorkflow.renderAsState( props = Unit, runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig(), onOutput = {} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloBinding.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloBinding.kt deleted file mode 100644 index 0309a095b4..0000000000 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloBinding.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.squareup.sample.compose.hellocompose - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import com.squareup.sample.compose.hellocompose.HelloWorkflow.Rendering -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.composeScreenViewFactory - -@OptIn(WorkflowUiExperimentalApi::class) -val HelloBinding = composeScreenViewFactory { rendering, _ -> - Text( - rendering.message, - modifier = Modifier - .clickable(onClick = rendering.onClick) - .fillMaxSize() - .wrapContentSize(Alignment.Center) - ) -} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt new file mode 100644 index 0000000000..88e8f8b87b --- /dev/null +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt @@ -0,0 +1,40 @@ +package com.squareup.sample.compose.hellocompose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.ComposeScreen +import com.squareup.workflow1.ui.compose.tooling.Preview + +@OptIn(WorkflowUiExperimentalApi::class) +data class HelloComposeScreen( + val message: String, + val onClick: () -> Unit +) : ComposeScreen { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + Text( + message, + modifier = Modifier + .clickable(onClick = onClick) + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) + } +} + +@OptIn(WorkflowUiExperimentalApi::class) +@Preview(heightDp = 150, showBackground = true) +@Composable +private fun HelloPreview() { + HelloComposeScreen( + "Hello!", + onClick = {} + ).Preview() +} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeWorkflow.kt similarity index 59% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloWorkflow.kt rename to samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeWorkflow.kt index 16bc8142b9..9d78d09de2 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeWorkflow.kt @@ -1,17 +1,14 @@ package com.squareup.sample.compose.hellocompose -import com.squareup.sample.compose.hellocompose.HelloWorkflow.Rendering -import com.squareup.sample.compose.hellocompose.HelloWorkflow.State -import com.squareup.sample.compose.hellocompose.HelloWorkflow.State.Goodbye -import com.squareup.sample.compose.hellocompose.HelloWorkflow.State.Hello +import com.squareup.sample.compose.hellocompose.HelloComposeWorkflow.State +import com.squareup.sample.compose.hellocompose.HelloComposeWorkflow.State.Goodbye +import com.squareup.sample.compose.hellocompose.HelloComposeWorkflow.State.Hello import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.action import com.squareup.workflow1.parse -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -object HelloWorkflow : StatefulWorkflow() { +object HelloComposeWorkflow : StatefulWorkflow() { enum class State { Hello, Goodbye; @@ -22,12 +19,6 @@ object HelloWorkflow : StatefulWorkflow() { } } - @OptIn(WorkflowUiExperimentalApi::class) - data class Rendering( - val message: String, - val onClick: () -> Unit - ) : Screen - private val helloAction = action { state = state.theOtherState() } @@ -42,7 +33,7 @@ object HelloWorkflow : StatefulWorkflow() { renderProps: Unit, renderState: State, context: RenderContext - ): Rendering = Rendering( + ): HelloComposeScreen = HelloComposeScreen( message = renderState.name, onClick = { context.actionSink.send(helloAction) } ) diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt index be9dac792a..aa3f65e67d 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt @@ -9,11 +9,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.squareup.sample.compose.hellocomposebinding.HelloWorkflow.Rendering import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.composeScreenViewFactory +import com.squareup.workflow1.ui.compose.ScreenComposableFactory import com.squareup.workflow1.ui.compose.tooling.Preview @OptIn(WorkflowUiExperimentalApi::class) -val HelloBinding = composeScreenViewFactory { rendering, _ -> +val HelloBinding = ScreenComposableFactory { rendering, _ -> Text( rendering.message, modifier = Modifier diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt index e175ac0480..f20954ba3c 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt @@ -17,6 +17,7 @@ import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.withComposeInteropSupport import com.squareup.workflow1.ui.compose.withCompositionRoot import com.squareup.workflow1.ui.plus import com.squareup.workflow1.ui.renderWorkflowIn @@ -25,13 +26,15 @@ import kotlinx.coroutines.flow.StateFlow @OptIn(WorkflowUiExperimentalApi::class) private val viewEnvironment = - (ViewEnvironment.EMPTY + ViewRegistry(HelloBinding)).withCompositionRoot { content -> - MaterialTheme(content = content) - } + (ViewEnvironment.EMPTY + ViewRegistry(HelloBinding)) + .withCompositionRoot { content -> + MaterialTheme(content = content) + } + .withComposeInteropSupport() /** * Demonstrates how to create and display a view factory with - * [composeScreenViewFactory][com.squareup.workflow1.ui.compose.composeScreenViewFactory]. + * [screenComposableFactory][com.squareup.workflow1.ui.compose.ScreenComposableFactory]. */ class HelloBindingActivity : AppCompatActivity() { @OptIn(WorkflowUiExperimentalApi::class) diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt index 8526eb7b6a..478cd73e03 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt @@ -10,10 +10,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools +import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.withComposeInteropSupport import com.squareup.workflow1.ui.renderWorkflowIn +import com.squareup.workflow1.ui.withEnvironment import kotlinx.coroutines.flow.StateFlow class HelloComposeWorkflowActivity : AppCompatActivity() { @@ -30,7 +34,9 @@ class HelloComposeWorkflowActivity : AppCompatActivity() { @OptIn(WorkflowUiExperimentalApi::class) val renderings: StateFlow by lazy { renderWorkflowIn( - workflow = HelloWorkflow, + workflow = HelloWorkflow.mapRendering { + it.withEnvironment(ViewEnvironment.EMPTY.withComposeInteropSupport()) + }, scope = viewModelScope, savedStateHandle = savedState, runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt index b6f50e7dc2..8386aaa999 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt @@ -10,10 +10,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools +import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.withComposeInteropSupport import com.squareup.workflow1.ui.renderWorkflowIn +import com.squareup.workflow1.ui.withEnvironment import kotlinx.coroutines.flow.StateFlow /** @@ -34,7 +38,9 @@ class InlineRenderingActivity : AppCompatActivity() { @OptIn(WorkflowUiExperimentalApi::class) val renderings: StateFlow by lazy { renderWorkflowIn( - workflow = InlineRenderingWorkflow, + workflow = InlineRenderingWorkflow.mapRendering { + it.withEnvironment(ViewEnvironment.EMPTY.withComposeInteropSupport()) + }, scope = viewModelScope, savedStateHandle = savedState, runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt index 8e53b70265..3e77d96fb1 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt @@ -21,14 +21,14 @@ import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.parse -import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.ComposeScreen import com.squareup.workflow1.ui.compose.WorkflowRendering import com.squareup.workflow1.ui.compose.renderAsState -object InlineRenderingWorkflow : StatefulWorkflow>() { +object InlineRenderingWorkflow : StatefulWorkflow() { override fun initialState( props: Unit, @@ -39,7 +39,7 @@ object InlineRenderingWorkflow : StatefulWorkflow = ComposeScreen { + ) = ComposeScreen { Box { Button(onClick = context.eventHandler { state += 1 }) { Text("Counter: ") diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/Samples.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/Samples.kt index 66bb94a2ae..21426f6f46 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/Samples.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/Samples.kt @@ -37,7 +37,7 @@ val samples = listOf( Sample( "Hello Compose Binding", HelloBindingActivity::class, - "Creates a ViewFactory using composeViewFactory." + "Binds a Screen to a UI factory using ScreenComposableFactory()." ) { DrawHelloRenderingPreview() }, Sample( "Nested Renderings", diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/LegacyRunner.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/LegacyRunner.kt index e9296e3dd3..6510d6f934 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/LegacyRunner.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/LegacyRunner.kt @@ -6,8 +6,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.squareup.sample.compose.databinding.LegacyViewBinding import com.squareup.sample.compose.nestedrenderings.RecursiveWorkflow.LegacyRendering -import com.squareup.workflow1.ui.ScreenViewFactory -import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromViewBinding import com.squareup.workflow1.ui.ScreenViewRunner import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi @@ -25,19 +23,13 @@ class LegacyRunner(private val binding: LegacyViewBinding) : ScreenViewRunner by fromViewBinding( - LegacyViewBinding::inflate, - ::LegacyRunner - ) } @OptIn(WorkflowUiExperimentalApi::class) @Preview(widthDp = 200, heightDp = 150, showBackground = true) @Composable private fun LegacyRunnerPreview() { - LegacyRunner.Preview( - rendering = LegacyRendering(StringRendering("child")), + LegacyRendering(StringRendering("child")).Preview( placeholderModifier = Modifier.fillMaxSize() ) } diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt index 51a2962724..ca8a06b5ac 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt @@ -18,6 +18,7 @@ import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.withComposeInteropSupport import com.squareup.workflow1.ui.compose.withCompositionRoot import com.squareup.workflow1.ui.plus import com.squareup.workflow1.ui.renderWorkflowIn @@ -25,18 +26,17 @@ import com.squareup.workflow1.ui.withEnvironment import kotlinx.coroutines.flow.StateFlow @OptIn(WorkflowUiExperimentalApi::class) -private val viewRegistry = ViewRegistry( - RecursiveViewFactory, - LegacyRunner -) +private val viewRegistry = ViewRegistry(RecursiveViewFactory) @OptIn(WorkflowUiExperimentalApi::class) private val viewEnvironment = - (ViewEnvironment.EMPTY + viewRegistry).withCompositionRoot { content -> - CompositionLocalProvider(LocalBackgroundColor provides Color.Green) { - content() + (ViewEnvironment.EMPTY + viewRegistry) + .withCompositionRoot { content -> + CompositionLocalProvider(LocalBackgroundColor provides Color.Green) { + content() + } } - } + .withComposeInteropSupport() @WorkflowUiExperimentalApi class NestedRenderingsActivity : AppCompatActivity() { diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt index 648596c3b2..793414abf2 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt @@ -27,7 +27,7 @@ import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.WorkflowRendering -import com.squareup.workflow1.ui.compose.composeScreenViewFactory +import com.squareup.workflow1.ui.compose.ScreenComposableFactory import com.squareup.workflow1.ui.compose.tooling.Preview /** @@ -39,7 +39,7 @@ val LocalBackgroundColor = compositionLocalOf { error("No background colo * A `ViewFactory` that renders [RecursiveWorkflow.Rendering]s. */ @OptIn(WorkflowUiExperimentalApi::class) -val RecursiveViewFactory = composeScreenViewFactory { rendering, viewEnvironment -> +val RecursiveViewFactory = ScreenComposableFactory { rendering, viewEnvironment -> // Every child should be drawn with a slightly-darker background color. val color = LocalBackgroundColor.current val childColor = remember(color) { diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt index 172452a086..a252484f90 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt @@ -1,5 +1,6 @@ package com.squareup.sample.compose.nestedrenderings +import com.squareup.sample.compose.databinding.LegacyViewBinding import com.squareup.sample.compose.nestedrenderings.RecursiveWorkflow.LegacyRendering import com.squareup.sample.compose.nestedrenderings.RecursiveWorkflow.Rendering import com.squareup.sample.compose.nestedrenderings.RecursiveWorkflow.State @@ -7,7 +8,9 @@ import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.action import com.squareup.workflow1.renderChild +import com.squareup.workflow1.ui.AndroidScreen import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.WorkflowUiExperimentalApi /** @@ -39,7 +42,14 @@ object RecursiveWorkflow : StatefulWorkflow() { /** * Wrapper around a [Rendering] that will be implemented using a legacy view. */ - data class LegacyRendering(val rendering: Screen) : Screen + data class LegacyRendering( + val rendering: Screen + ) : AndroidScreen { + override val viewFactory = ScreenViewFactory.fromViewBinding( + LegacyViewBinding::inflate, + ::LegacyRunner + ) + } override fun initialState( props: Unit, diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt index 51872aacd9..1606fc23bc 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt @@ -21,9 +21,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.ComposeScreen import com.squareup.workflow1.ui.compose.WorkflowRendering -import com.squareup.workflow1.ui.compose.composeScreenViewFactory import com.squareup.workflow1.ui.compose.tooling.Preview class PreviewActivity : AppCompatActivity() { @@ -49,7 +50,7 @@ val previewContactRendering = ContactRendering( fun PreviewApp() { MaterialTheme { Surface { - contactViewFactory.Preview(previewContactRendering) + previewContactRendering.Preview() } } } @@ -57,33 +58,40 @@ fun PreviewApp() { data class ContactRendering( val name: String, val details: ContactDetailsRendering -) : Screen +) : ComposeScreen { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + ContactDetails(this, viewEnvironment) + } +} data class ContactDetailsRendering( val phoneNumber: String, val address: String ) : Screen -private val contactViewFactory = - composeScreenViewFactory { rendering, environment -> - Card( - modifier = Modifier - .padding(8.dp) - .clickable { /* handle click */ } +@Composable +private fun ContactDetails( + rendering: ContactRendering, + environment: ViewEnvironment +) { + Card( + modifier = Modifier + .padding(8.dp) + .clickable { /* handle click */ } + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = spacedBy(8.dp), ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = spacedBy(8.dp), - ) { - Text(rendering.name, style = MaterialTheme.typography.body1) - WorkflowRendering( - rendering = rendering.details, - viewEnvironment = environment, - modifier = Modifier - .aspectRatio(1f) - .border(0.dp, Color.LightGray) - .padding(8.dp) - ) - } + Text(rendering.name, style = MaterialTheme.typography.body1) + WorkflowRendering( + rendering = rendering.details, + viewEnvironment = environment, + modifier = Modifier + .aspectRatio(1f) + .border(0.dp, Color.LightGray) + .padding(8.dp) + ) } } +} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt index 994e7db4c6..7f06381c84 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt @@ -20,11 +20,11 @@ import com.squareup.sample.compose.textinput.TextInputWorkflow.Rendering import com.squareup.workflow1.ui.TextController import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.asMutableState -import com.squareup.workflow1.ui.compose.composeScreenViewFactory +import com.squareup.workflow1.ui.compose.ScreenComposableFactory import com.squareup.workflow1.ui.compose.tooling.Preview @OptIn(WorkflowUiExperimentalApi::class) -val TextInputViewFactory = composeScreenViewFactory { rendering, _ -> +val TextInputViewFactory = ScreenComposableFactory { rendering, _ -> Column( modifier = Modifier .fillMaxSize() diff --git a/workflow-ui/compose-tooling/README.md b/workflow-ui/compose-tooling/README.md deleted file mode 100644 index d912a3aa6e..0000000000 --- a/workflow-ui/compose-tooling/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Module compose-tooling - -TODO diff --git a/workflow-ui/compose-tooling/api/compose-tooling.api b/workflow-ui/compose-tooling/api/compose-tooling.api index a3f8dd69ad..cc112ebd41 100644 --- a/workflow-ui/compose-tooling/api/compose-tooling.api +++ b/workflow-ui/compose-tooling/api/compose-tooling.api @@ -1,11 +1,13 @@ -public final class com/squareup/workflow1/ui/compose/tooling/ComposableSingletons$ViewFactoriesKt { - public static final field INSTANCE Lcom/squareup/workflow1/ui/compose/tooling/ComposableSingletons$ViewFactoriesKt; +public final class com/squareup/workflow1/ui/compose/tooling/ComposableSingletons$PreviewsKt { + public static final field INSTANCE Lcom/squareup/workflow1/ui/compose/tooling/ComposableSingletons$PreviewsKt; public static field lambda-1 Lkotlin/jvm/functions/Function4; public fun ()V public final fun getLambda-1$wf1_compose_tooling ()Lkotlin/jvm/functions/Function4; } -public final class com/squareup/workflow1/ui/compose/tooling/ViewFactoriesKt { +public final class com/squareup/workflow1/ui/compose/tooling/PreviewsKt { + public static final fun Preview (Lcom/squareup/workflow1/ui/Screen;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final fun Preview (Lcom/squareup/workflow1/ui/ScreenViewFactory;Lcom/squareup/workflow1/ui/Screen;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun Preview (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory;Lcom/squareup/workflow1/ui/Screen;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } diff --git a/workflow-ui/compose-tooling/src/androidTest/java/com/squareup/workflow1/ui/compose/tooling/PreviewViewFactoryTest.kt b/workflow-ui/compose-tooling/src/androidTest/java/com/squareup/workflow1/ui/compose/tooling/PreviewViewFactoryTest.kt index 5a9012044a..5ab7c993b9 100644 --- a/workflow-ui/compose-tooling/src/androidTest/java/com/squareup/workflow1/ui/compose/tooling/PreviewViewFactoryTest.kt +++ b/workflow-ui/compose-tooling/src/androidTest/java/com/squareup/workflow1/ui/compose/tooling/PreviewViewFactoryTest.kt @@ -18,7 +18,7 @@ import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironmentKey import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.WorkflowRendering -import com.squareup.workflow1.ui.compose.composeScreenViewFactory +import com.squareup.workflow1.ui.compose.ScreenComposableFactory import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule import leakcanary.DetectLeaksAfterTestSuccess @@ -96,7 +96,7 @@ internal class PreviewViewFactoryTest { } private val ParentWithOneChild = - composeScreenViewFactory { rendering, environment -> + ScreenComposableFactory { rendering, environment -> Column { BasicText(rendering.first.text) WorkflowRendering(rendering.second, environment) @@ -109,7 +109,7 @@ internal class PreviewViewFactoryTest { } private val ParentWithTwoChildren = - composeScreenViewFactory { rendering, environment -> + ScreenComposableFactory { rendering, environment -> Column { WorkflowRendering(rendering.first, environment) BasicText(rendering.second.text) @@ -156,7 +156,7 @@ internal class PreviewViewFactoryTest { ) : Screen private val ParentRecursive = - composeScreenViewFactory { rendering, environment -> + ScreenComposableFactory { rendering, environment -> Column { BasicText(rendering.text) rendering.child?.let { child -> @@ -198,7 +198,7 @@ internal class PreviewViewFactoryTest { override val default: String get() = error("Not specified") } - private val ParentConsumesCustomKey = composeScreenViewFactory { _, environment -> + private val ParentConsumesCustomKey = ScreenComposableFactory { _, environment -> BasicText(environment[TestEnvironmentKey]) } diff --git a/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/PlaceholderViewFactory.kt b/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/PlaceholderViewFactory.kt index e2b544b6f7..67ac0bd9e5 100644 --- a/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/PlaceholderViewFactory.kt +++ b/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/PlaceholderViewFactory.kt @@ -25,15 +25,17 @@ import androidx.compose.ui.unit.dp import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.composeScreenViewFactory +import com.squareup.workflow1.ui.compose.ScreenComposableFactory /** - * A [ScreenViewFactory] that will be used any time a [PreviewScreenViewFactoryFinder] + * A [ScreenComposableFactory] that will be used any time a [PreviewScreenComposableFactoryFinder] * is asked to show a rendering. It displays a placeholder graphic and the rendering's * `toString()` result. */ -internal fun placeholderScreenViewFactory(modifier: Modifier): ScreenViewFactory = - composeScreenViewFactory { rendering, _ -> +internal fun placeholderScreenComposableFactory( + modifier: Modifier +): ScreenComposableFactory = + ScreenComposableFactory { rendering, _ -> BoxWithConstraints { BasicText( modifier = modifier diff --git a/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/PreviewViewEnvironment.kt b/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/PreviewViewEnvironment.kt index 6201160773..f497bb3ab9 100644 --- a/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/PreviewViewEnvironment.kt +++ b/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/PreviewViewEnvironment.kt @@ -11,25 +11,39 @@ import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewFactoryFinder import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.ScreenComposableFactory +import com.squareup.workflow1.ui.compose.ScreenComposableFactoryFinder +import com.squareup.workflow1.ui.compose.asViewFactory /** * Creates and [remember]s a [ViewEnvironment] that has a special [ScreenViewFactoryFinder] * and any additional elements as configured by [viewEnvironmentUpdater]. * * The [ScreenViewFactoryFinder] will contain [mainFactory] if specified, as well as a - * [placeholderScreenViewFactory] that will be used to show any renderings that don't match + * [placeholderScreenComposableFactory] that will be used to show any renderings that don't match * [mainFactory]'s type. All placeholders will have [placeholderModifier] applied. */ @Composable internal fun rememberPreviewViewEnvironment( placeholderModifier: Modifier, viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null, - mainFactory: ScreenViewFactory<*>? = null + mainFactory: ScreenComposableFactory<*>? = null ): ViewEnvironment { - val finder = remember(mainFactory, placeholderModifier) { - PreviewScreenViewFactoryFinder(mainFactory, placeholderScreenViewFactory(placeholderModifier)) + val composableFactoryFinder = remember(mainFactory, placeholderModifier) { + PreviewScreenComposableFactoryFinder( + mainFactory, + placeholderScreenComposableFactory(placeholderModifier) + ) } - return remember(finder, viewEnvironmentUpdater) { - (ViewEnvironment.EMPTY + (ScreenViewFactoryFinder to finder)).let { environment -> + val screenFactoryFinder = remember(mainFactory, placeholderModifier) { + PreviewScreenViewFactoryFinder( + mainFactory?.asViewFactory(), + placeholderScreenComposableFactory(placeholderModifier).asViewFactory() + ) + } + return remember(composableFactoryFinder, viewEnvironmentUpdater) { + (ViewEnvironment.EMPTY + + (ScreenComposableFactoryFinder to composableFactoryFinder) + + (ScreenViewFactoryFinder to screenFactoryFinder)).let { environment -> // Give the preview a chance to add its own elements to the ViewEnvironment. viewEnvironmentUpdater?.let { it(environment) } ?: environment } @@ -37,11 +51,35 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi } /** - * A [ScreenViewFactoryFinder] that uses [mainFactory] for rendering [RenderingT]s, + * A [ScreenComposableFactoryFinder] that uses [mainFactory] for rendering [RenderingT]s, * and [placeholderFactory] for all other * [WorkflowRendering][com.squareup.workflow1.ui.compose.WorkflowRendering] calls. */ @Immutable +private class PreviewScreenComposableFactoryFinder( + private val mainFactory: ScreenComposableFactory? = null, + private val placeholderFactory: ScreenComposableFactory +) : ScreenComposableFactoryFinder { + @OptIn(WorkflowUiExperimentalApi::class) + override fun getComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenComposableFactory = + // This `isInstance()` check is a bit sketchy b/c the real code insists on + // the types being exactly the same, but this is the easiest way to keep + // ComposeScreen, AndroidScreen working. + if (mainFactory?.type?.isInstance(rendering) == true) { + @Suppress("UNCHECKED_CAST") + mainFactory as ScreenComposableFactory + } else { + placeholderFactory + } +} + +/** + * [ScreenViewFactoryFinder] analog to [PreviewScreenComposableFactoryFinder]. + */ +@Immutable private class PreviewScreenViewFactoryFinder( private val mainFactory: ScreenViewFactory? = null, private val placeholderFactory: ScreenViewFactory diff --git a/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/Previews.kt b/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/Previews.kt new file mode 100644 index 0000000000..d7f3f9820f --- /dev/null +++ b/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/Previews.kt @@ -0,0 +1,111 @@ +package com.squareup.workflow1.ui.compose.tooling + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewFactoryFinder +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.ScreenComposableFactory +import com.squareup.workflow1.ui.compose.ScreenComposableFactoryFinder +import com.squareup.workflow1.ui.compose.WorkflowRendering +import com.squareup.workflow1.ui.compose.asComposableFactory + +/** + * Uses [ScreenComposableFactory.Preview] or [ScreenViewFactory.Preview] + * to draw the receiving [Screen]. + * + * Note that this function can preview any kind of [Screen], whether it's bound + * to UI code implemented via Compose or classic [View][android.view.View] code. + * + * Use inside `@Preview` Composable functions: + * + * @Preview(heightDp = 150, showBackground = true) + * @Composable + * fun HelloPreview() { + * HelloScreen( + * "Hello!", + * onClick = {} + * ).Preview() + * } + */ +@WorkflowUiExperimentalApi +@Composable +public fun Screen.Preview( + modifier: Modifier = Modifier, + placeholderModifier: Modifier = Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null +) { + val factoryEnv = (viewEnvironmentUpdater?.invoke(ViewEnvironment.EMPTY) + ?: ViewEnvironment.EMPTY) + + factoryEnv[ScreenComposableFactoryFinder] + .getComposableFactoryForRendering(factoryEnv, this) + ?.Preview(this, modifier, placeholderModifier, viewEnvironmentUpdater)?.also { return } + + factoryEnv[ScreenViewFactoryFinder] + .getViewFactoryForRendering(factoryEnv, this) + ?.Preview(this, modifier, placeholderModifier, viewEnvironmentUpdater) +} + +/** + * Draws this [ScreenComposableFactory] using a special preview [ScreenComposableFactoryFinder]. + * + * Use inside `@Preview` Composable functions: + * + * @Preview(heightDp = 150, showBackground = true) + * @Composable + * fun DrawHelloRenderingPreview() { + * HelloBinding.Preview(HelloScreen("Hello!", onClick = {})) + * } + * + * *Note: [rendering] must be the same type as this [ScreenComposableFactory], even though the type + * system does not enforce this constraint. This is due to a Compose compiler bug tracked + * [here](https://issuetracker.google.com/issues/156527332).* + * + * @param modifier [Modifier] that will be applied to this [ScreenComposableFactory]. + * @param placeholderModifier [Modifier] that will be applied to any nested renderings this factory + * shows. + * @param viewEnvironmentUpdater Function that configures the [ViewEnvironment] passed to this + * factory. + */ +@WorkflowUiExperimentalApi +@Composable +public fun ScreenComposableFactory.Preview( + rendering: RenderingT, + modifier: Modifier = Modifier, + placeholderModifier: Modifier = Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null +) { + val previewEnvironment = + rememberPreviewViewEnvironment(placeholderModifier, viewEnvironmentUpdater, mainFactory = this) + WorkflowRendering(rendering, previewEnvironment, modifier) +} + +/** + * Like [ScreenComposableFactory.Preview], but for non-Compose [ScreenViewFactory] instances. + * Yes, you can preview classic [View][android.view.View] code this way. + * + * Use inside `@Preview` Composable functions. + * + * *Note: [rendering] must be the same type as this [ScreenViewFactory], even though the type + * system does not enforce this constraint. This is due to a Compose compiler bug tracked + * [here](https://issuetracker.google.com/issues/156527332).* + * + * @param modifier [Modifier] that will be applied to this [ScreenViewFactory]. + * @param placeholderModifier [Modifier] that will be applied to any nested renderings this factory + * shows. + * @param viewEnvironmentUpdater Function that configures the [ViewEnvironment] passed to this + * factory. + */ +@WorkflowUiExperimentalApi +@Composable +public fun ScreenViewFactory.Preview( + rendering: RenderingT, + modifier: Modifier = Modifier, + placeholderModifier: Modifier = Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null +) { + asComposableFactory().Preview(rendering, modifier, placeholderModifier, viewEnvironmentUpdater) +} diff --git a/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/ViewFactories.kt b/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/ViewFactories.kt deleted file mode 100644 index 334b76204f..0000000000 --- a/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/ViewFactories.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.squareup.workflow1.ui.compose.tooling - -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactory -import com.squareup.workflow1.ui.ScreenViewFactoryFinder -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.WorkflowRendering -import com.squareup.workflow1.ui.compose.composeScreenViewFactory - -/** - * Draws this [ScreenViewFactory] using a special preview [ScreenViewFactoryFinder]. - * - * Use inside `@Preview` Composable functions. - * - * *Note: [rendering] must be the same type as this [ScreenViewFactory], even though the type - * system does not enforce this constraint. This is due to a Compose compiler bug tracked - * [here](https://issuetracker.google.com/issues/156527332).* - * - * @param modifier [Modifier] that will be applied to this [ScreenViewFactory]. - * @param placeholderModifier [Modifier] that will be applied to any nested renderings this factory - * shows. - * @param viewEnvironmentUpdater Function that configures the [ViewEnvironment] passed to this - * factory. - */ -@WorkflowUiExperimentalApi -@Composable -public fun ScreenViewFactory.Preview( - rendering: RenderingT, - modifier: Modifier = Modifier, - placeholderModifier: Modifier = Modifier, - viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null -) { - val previewEnvironment = - rememberPreviewViewEnvironment(placeholderModifier, viewEnvironmentUpdater, mainFactory = this) - WorkflowRendering(rendering, previewEnvironment, modifier) -} - -@OptIn(WorkflowUiExperimentalApi::class) -@Preview(showBackground = true) -@Composable -private fun ViewFactoryPreviewPreview() { - val factory = composeScreenViewFactory { _, environment -> - Column( - verticalArrangement = spacedBy(8.dp), - modifier = Modifier.padding(8.dp) - ) { - BasicText("Top text") - WorkflowRendering( - rendering = TextRendering( - "Child rendering with very long text to suss out cross-hatch rendering edge cases", - ), - viewEnvironment = environment, - modifier = Modifier - .aspectRatio(1f) - .padding(8.dp) - ) - BasicText("Bottom text") - } - } - - factory.Preview(object : Screen {}) -} - -@OptIn(WorkflowUiExperimentalApi::class) -private data class TextRendering(val text: String) : Screen diff --git a/workflow-ui/compose/README.md b/workflow-ui/compose/README.md index 4f2c8b6b28..404212e37a 100644 --- a/workflow-ui/compose/README.md +++ b/workflow-ui/compose/README.md @@ -1,11 +1,9 @@ # Module compose This module hosts the workflow-ui compose integration, and this file describes in detail how that integration works and why. -It was originally published as a [blog post](https://developer.squareup.com/blog/jetpack-compose-support-in-workflow). - -## Timeline and Process -Compose entered beta in the first half of 2020. Since we were all locked in our homes with no social lives, it was the perfect time to start exploring what integration between Compose and Workflows would look like. This was very experimental work — Compose APIs were changing drastically every two weeks. To say the least, it was not “ready for production.” However, it was important to suss out what sort of integration points were available to us, what API shapes felt natural, and where the rough edges were. In addition to figuring out our own adoption story, we have also been able to contribute a lot of feedback to Google (see our [case study](https://developer.android.com/stories/apps/square-compose)), and some of the features we initially wrote specifically for workflow integration ended up making it into the library (e.g. automatic subcomposition linking in child `View`s). +It was originally published as a [blog post](https://developer.squareup.com/blog/jetpack-compose-support-in-workflow). +Since then, our approach has been overhauled. With that overhaul this document has been updated to reflect how the new system works, but it's still very deisgn-doc like. To skip past all the big picture and implementation verbiage and get right to how to use this stuff, jump down to [API Design][#api-design] below. ## Goals and Non-Goals @@ -23,16 +21,15 @@ At Square, when we start a project, we like to enumerate and distinguish goals a ### Non-Goals - Convert existing screens in our apps to Compose. -- Provide design system components in Compose. (This is planned, but as a separate project that depends on this one.) -- Anything with our own internal declarative UI toolkit, Mosaic (sunsetting it, integrating with it, or otherwise). +- Provide design system components in Compose. ## Major Components -There are a few major areas this project needs to focus on to support Compose from Workflows: navigation, `ViewFactory` support, and hosting. +There are a few major areas this project needs to focus on to support Compose from Workflows: navigation, UI factory support (that is, `ScreenViewFactory` and the other types collected by `ViewRegistry`), and hosting. ### Navigation support -Workflow isn’t just a state management library — Workflow UI includes navigation containers for things like [backstacks](https://github.com/square/workflow-kotlin/blob/v1.0.0-alpha18/workflow-ui/backstack-common/src/main/java/com/squareup/workflow1/ui/backstack/BackStackScreen.kt) and [modals](https://github.com/square/workflow-kotlin/blob/v1.0.0-alpha18/workflow-ui/modal-common/src/main/java/com/squareup/workflow1/ui/modal/HasModals.kt) (Support for complex navigation logic was one of our main drivers in writing the library — we outgrew things like Jetpack Navigation a long time ago.). +Workflow isn’t just a state management library — Workflow UI includes navigation containers for things like [backstacks](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BackStackScreen.kt) and [windows](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Overlay.kt) -- including [modals](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/ModalOverlay.kt). (Support for complex navigation logic was one of our main drivers in writing the library — we outgrew things like Jetpack Navigation a long time ago.). Because these containers define “lifecycles” for parts of the UI, they need to communicate that to the Compose primitives through the AndroidX concepts of [`LifecycleOwner`](https://developer.android.com/reference/androidx/lifecycle/LifecycleOwner) and [`SavedStateRegistry`](https://developer.android.com/reference/kotlin/androidx/savedstate/SavedStateRegistry). When a composition is hosted inside an Android `View`, the [`AbstractComposeView`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/AbstractComposeView) that bridges the two reads the [`ViewTreeLifecycleOwner`](https://developer.android.com/reference/androidx/lifecycle/ViewTreeLifecycleOwner) to find the nearest `Lifecycle` responsible for that view. @@ -40,17 +37,17 @@ Because these containers define “lifecycles” for parts of the UI, they need The `Lifecycle` is then observed, both to know when it is safe to restore state, and to know when to dispose the composition because the navigation element is going away. The view also reads the [`SavedStateRegistry`](https://developer.android.com/reference/androidx/savedstate/SavedStateRegistryOwner), wraps it in a [`SaveableStateRegistry`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/saveable/SaveableStateRegistry), and provides it to the composition via the `LocalSaveableStateRegistry`. As per the [`SavedStateRegistry` contract](https://developer.android.com/reference/androidx/savedstate/SavedStateRegistry#consumeRestoredStateForKey(java.lang.String)), the registry is asked to restore the composition state as soon as the `Lifecycle` moves to the `CREATED` state. Any `rememberSaveable` calls in the composition will use this mechanism to save and restore their state. -In order for this wiring to all work with Workflows, the Workflow navigation containers must correctly publish `Lifecycle`s and `SavedStateRegistry`s for their child views. The container already manages state saving and restoration via the Android `View` “hierarchy state” mechanism that all `View` classes participate in, so it’s not much of a stretch for them to support this new AndroidX stuff as well. The tricky part is that the sequencing of these different state mechanisms is picky and a little complicated, and we ideally want the Workflow code to support this stuff even if the Workflow view root is hosted in an environment that doesn’t (e.g. a non-AndroidX `Activity`). +In order for this wiring to all work with Workflows, the Workflow navigation containers must correctly publish `Lifecycle`s and `SavedStateRegistry`s for their child views. The containers already manage state saving and restoration via the Android `View` “hierarchy state” mechanism that all `View` classes participate in, so it’s not much of a stretch for them to support this new AndroidX stuff as well. The tricky part is that the sequencing of these different state mechanisms is picky and a little complicated, and we ideally want the Workflow code to support this stuff even if the Workflow view root is hosted in an environment that doesn’t (e.g. a non-AndroidX `Activity`). > None of the AndroidX integrations described in this section actually have anything to do with Compose specifically. They are required for any code that makes use of the AndroidX `ViewTree*Owners` from within a Workflow view tree. Compose just happens to rely on this infrastructure, so Workflow has to support it in order to support Compose correctly. #### `Lifecycle` -For `LifecycleOwner` support, we need to think of anything that can ask the `ViewRegistry` for a view as a `LifecycleOwner`. This is because all such containers know when they are going to stop showing a particular child view (e.g. because the rendering type has changed, or a rendering is otherwise incompatible with the current one, and a new view must be created and bound). When that happens, they need to move the `Lifecycle` to the `DESTROYED` state to ensure the composition will be disposed. +For `LifecycleOwner` support, we need to think of anything that can ask the `ViewRegistry` to build a view as a `LifecycleOwner`. This is because all such containers know when they are going to stop showing a particular child view (e.g. because the rendering type has changed, or a rendering is otherwise incompatible with the current one, and a new view must be created and bound). When that happens, they need to move the `Lifecycle` to the `DESTROYED` state to ensure the hosted composition will be disposed. -We can provide an API for this so that containers only need to make a single call to dispose their lifecycle, and everything else “just works.” And luckily, most developers building features with Workflow will never write a container directly but instead use [`WorkflowViewStub`](https://github.com/square/workflow-kotlin/blob/v1.0.0-alpha18/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowViewStub.kt), which we will make do the right thing automatically. +We can provide an API for this so that containers only need to make a single call to dispose their lifecycle, and everything else “just works.” And luckily, most developers building features with Workflow will never write a container directly but instead use [`WorkflowViewStub`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowViewStub.kt), which we will make do the right thing automatically. -[`WorkflowLifecycleOwner`](https://github.com/square/workflow-kotlin/blob/v1.0.0-alpha18/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLifecycleOwner.kt) is the class we use to nest `Lifecycle`s. A `WorkflowLifecycleOwner` is a `LifecycleOwner` with a few extra semantics. `WorkflowLifecycleOwner`s form a tree. The lifecycle of a `WorkflowLifecycleOwner` will follow its parent, changing its own state any time the parent state changes, until either the parent enters the `DESTROYED` state or the `WorkflowLifecycleOwner` is explicitly destroyed. Thus, a tree of `WorkflowLifecycleOwner`s will be synced to the root `Lifecycle` (probably an `Activity`), but a container can set the state of an entire subtree to `DESTROYED` early – this will happen whenever the container is about to replace a view. When a container can show different views over its lifetime, it must install a `WorkflowLifecycleOwner` on each view it creates and destroy that owner when its view is about to be replaced. A `WorkflowLifecycleOwner`s automatically finds and observes its parent `Lifecycle` by the usual method — searching up the view tree. +[`WorkflowLifecycleOwner`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner.kt) is the class we use to nest `Lifecycle`s. A `WorkflowLifecycleOwner` is a `LifecycleOwner` with a few extra semantics. `WorkflowLifecycleOwner`s form a tree. The lifecycle of a `WorkflowLifecycleOwner` follows its parent, changing its own state any time the parent state changes, until either the parent enters the `DESTROYED` state or the `WorkflowLifecycleOwner` is explicitly destroyed. Thus, a tree of `WorkflowLifecycleOwner`s will be synced to the root `Lifecycle` (probably an `Activity` or a `Dialog`), but a container can set the state of an entire subtree to `DESTROYED` early – this will happen whenever the container is about to replace a view. When a container can show different views over its lifetime, it must install a `WorkflowLifecycleOwner` on each view it creates and destroy that owner when the managed view is about to be replaced. A `WorkflowLifecycleOwner`s automatically finds and observes its parent `Lifecycle` by the usual method — searching up the view tree. #### `SavedStateRegistry` @@ -58,21 +55,29 @@ We can provide an API for this so that containers only need to make a single cal Before all this AndroidX stuff, here’s how view state saving and restoration worked: -`View` is instantiated. Constructor probably performs some initialization, e.g. setting default `EditText` values. An ID should be set. -`View` is added as a child of a `ViewGroup` and attached to a window. -After the hosting `Activity` moves to the `STARTED` state, `onRestoreInstanceState` is called for every view in the hierarchy (even the `View`’s children, if it has any). `EditText`s, for example, use this callback to restore any previously-entered text. Because this callback happens after initialization, it looks to the app user like the text was just restored — they never see the initial value. -`View` gets arbitrarily-many calls to `onSaveInstanceState`. The last one of these before the view is destroyed is what may be used to restore the view later. +- `View` is instantiated. Constructor probably performs some initialization, e.g. setting default `EditText` values. An ID should be set. + +- `View` is added as a child of a `ViewGroup` and attached to a window. + +- After the hosting `Activity` moves to the `STARTED` state, `onRestoreInstanceState` is called for every view in the hierarchy (even the `View`’s children, if it has any). `EditText`s, for example, use this callback to restore any previously-entered text. Because this callback happens after initialization, it looks to the app user like the text was just restored — they never see the initial value. + +- `View` gets arbitrarily-many calls to `onSaveInstanceState`. The last one of these before the view is destroyed is what may be used to restore the view later. The old mechanism depends on `View`s having their IDs set. These IDs are used to associate state with particular views, since there is no other way to match view instances between different processes. Here’s how the view restoration system works with AndroidX’s `SavedStateRegistry`: -`View` is instantiated. Because the view hasn’t been attached to a parent yet, it can’t use the `ViewTree*Owner` functions. -`View` is eventually added to a `ViewGroup`, and attached to the window. Now the view has a parent, so the `onAttached` callback can search up the tree for the `ViewTreeLifecycleOwner`. It also looks for the `SavedStateRegistryOwner` — it can’t use it yet though. -One or more `SavedStateProvider`s are registered on the registry associated with arbitrary string keys — these providers are simply functions that will be called arbitrarily-many times to provide saved values when the system needs to save view state. -The `Lifecycle` is observed, as long as the view remains attached. -When the lifecycle state moves to `CREATED`, the `SavedStateRegistry` can be queried. The view’s initialization logic can now call `consumeRestoredStateForKey` to read back any previously-saved values associated with string keys. If there were no values available, null will be returned and the view should fallback to some default value. -When the view goes away, the `SavedStateProvider`s should be unregistered. +- `View` is instantiated. Because the view hasn’t been attached to a parent yet, it can’t use the `ViewTree*Owner` functions. + +- `View` is eventually added to a `ViewGroup`, and attached to the window. Now the view has a parent, so the `onAttached` callback can search up the tree for the `ViewTreeLifecycleOwner`. It also looks for the `SavedStateRegistryOwner` — it can’t use it yet though. + +- One or more `SavedStateProvider`s are registered on the registry associated with arbitrary string keys — these providers are simply functions that will be called arbitrarily-many times to provide saved values when the system needs to save view state. + +- The `Lifecycle` is observed, as long as the view remains attached. + +- When the lifecycle state moves to `CREATED`, the `SavedStateRegistry` can be queried. The view’s initialization logic can now call `consumeRestoredStateForKey` to read back any previously-saved values associated with string keys. If there were no values available, null will be returned and the view should fallback to some default value. + +- When the view goes away, the `SavedStateProvider`s should be unregistered. Note the difference in when the restoration happens relative to the lifecycle states. The following table summarizes the differences between the instance state mechanism and `SavedStateRegistry`. @@ -88,6 +93,7 @@ Note the difference in when the restoration happens relative to the lifecycle st These differences make tying these together and supporting both from a single container a little complicated. Every container must support both of these mechanisms, but ideally using a single source of truth for saved state. Because containers and `WorkflowViewStub`s can exist anywhere in a view tree, they must be able to identify themselves to the different state mechanisms appropriately. It turns out that the legacy instance state approach of using view IDs is capable of supporting the registry string key approach, but it’s not really feasible the other way around. So the source of truth needs to be the instance state, and the registry state is stored in and restored from that. + Because the `SavedStateRegistry` contract says that it must be consumable as soon as the lifecycle is in the `CREATED` state, containers must also be able to control the lifecycle to ensure that it isn’t moved to that state until they’ve had a chance to actually seed the registry with restored data. The last two points form a cycle: we don’t get the `onRestoreInstanceState` callback until we’re in the `STARTED` state, but we can’t advance our children’s lifecycle past the `CREATED` state until we have read the registry state out of the instance state and seeded the registry. So the sequence we need to implement is: @@ -105,85 +111,109 @@ We have looked at a few ways of implementing this: 1. Single source of state: view state. `BackStackContainer` only uses view state, and implements support for the newer registry APIs on top of classic view state. While easier to implement than (1), it requires changing `WorkflowLifecycleOwner` to give the container more control over the lifecycle to comply with `SavedStateRegistry`'s contract about when states can be restored. 1. Use both. `BackStackContainer` uses the classic view state hooks to manage classic view state, and uses `SavedStateRegistry` hooks to manage registry state. This allows each mechanism to keep its advantage, and doesn't require emulating one's behavior with the other. -### `ViewFactory` support +### UI Factory support -Again, Workflow UI is built around the [`ViewFactory`](https://github.com/square/workflow-kotlin/blob/v1.0.0-alpha18/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ViewFactory.kt) interface, functions that build and update classic Android View instances for each type of view model rendered by a Workflow tree. Because Compose supports seamless integration from and to the classic Android `View` world, technically we don’t really _need_ to do anything to allow people to write Compose code inside `ViewFactory`s, at least to get 90% support. However, by providing some more convenient APIs, we not only remove some boilerplate, but also create the opportunity for some simplifications. There are also some edge cases that require a little more effort that we actually _do_ need to build support into Workflows for. +Workflow UI is built around UI factory interfaces like [`ScreenViewFactory`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactory.kt) and [`OverlayDialogFactory`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactory.kt), functions that build and update classic Android `View` and `Dialog` instances for each type of view model rendered by a Workflow tree. To find the appropriate factory to express a rendering of a particular type, container classes like [`WorkflowViewStub`] delegate most of their work to factory finder interfaces like [`ScreenViewFactoryFinder`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactoryFinder.kt) and `[OverlayDialogFactoryFinder]`(https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder.kt). -Each View instantiated by a `ViewFactory` is managed by an implementation of the [`LayoutRunner`](https://github.com/square/workflow-kotlin/blob/v1.0.0-alpha18/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/LayoutRunner.kt) interface. We could make a similar interface for Compose-based factories, but since Composables are just functions, we don’t even need an interface. Compose-based `ViewFactory`s will all share a common supertype, and share the same wiring logic. This logic will encapsulate the correct wiring of `AbstractComposeView`s into the Workflow-managed view hierarchy, as well as wiring up the binding so that rendering changes are correctly propagated into the composition. (The detailed API for this is covered under API Design, below.) +To add seamless Compose support, we add a new UI factory and finder interface pair: [`ScreenComposableFactory`](https://github.com/square/workflow-kotlin/blob/9bfd5119fabd0a3dfbc25bf7d93e52c7b31bb4cd/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt) and [`ScreenComposableFactoryFinder`](https://github.com/square/workflow-kotlin/blob/9bfd5119fabd0a3dfbc25bf7d93e52c7b31bb4cd/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt). +And to make those as easy to use as possible, we introduce [`ComposeScreen`](https://github.com/square/workflow-kotlin/blob/9bfd5119fabd0a3dfbc25bf7d93e52c7b31bb4cd/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt), a Compose analog to [`AndroidScreen`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt) -We will also provide a construction analogous to `WorkflowViewStub` to allow Compose-based factories to idiomatically display child renderings. +We also provide [`@Composable fun WorkflowRendering()`](https://github.com/square/workflow-kotlin/blob/9bfd5119fabd0a3dfbc25bf7d93e52c7b31bb4cd/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt), a construction function analogous to `WorkflowViewStub` to allow Compose-based factories to idiomatically display child renderings. -The above two concepts coordinate, and when a Compose-based factory is delegating to a child rendering that is also bound to a composable factory, we can skip the detour out into the Android view world and simply call the child composable directly from the parent. +> You'll note that there is no `OverlayComposableFactory` family of interfaces. So far, all of our window management is strictly via classic Android `Dialog` calls. There is nothing stopping us (or you) from adding Compose-based `Overlay` support in the future as a replacement, but we're definitely not making any promises on that front. -Compose has a mechanism for sharing data implicitly between different composables that call each other. They’re called [“composition locals”](https://developer.android.com/jetpack/compose/compositionlocal). A composable can “provide” a value for a given [`CompositionLocal`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/CompositionLocal) (or “local” for short) for a particular subtree of composables underneath it. Locals always flow down the tree. They are type-safe. Each is defined by a global property that provides a process-global “key” for the local, associates it with the type of value it can hold, and the default value if a composable tries reading it before any value has been provided. +Finally, we provide support for gluing together the Classic and Compose worlds. The [`ViewEnvironment.withComposeInteropSupport()`](https://github.com/square/workflow-kotlin/blob/9bfd5119fabd0a3dfbc25bf7d93e52c7b31bb4cd/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt) function replaces the `ScreenViewFactoryFinder` and `ScreenComposableFactory` objects in the receiver with implementations that are able to delegate to the other type. -Within a composition, even if that composition includes subcompositions, these locals flow seamlessly down the composition from parents to children. However, they also flow correctly down the tree if a composition includes an embedded `AndroidView` that in turns embeds another composition. Compose sets a view tag on Android `View`s hosted in compositions with a special value that will be read by child `AbstractComposeView`s to link the compositions and ensure locals continue to flow. This means that for most cases Workflow doesn’t need to do anything special to make this work. +All of the above coordinates nicely, so that when a Compose-based factory is delegating to a child rendering that is also bound to a composable factory, we can skip the detour out into the Android view world and simply call the child composable directly from the parent. It is also possible to provide both Classic and Compose treatments of any type of rendering. We use this technique with the standard [`NamedScreen`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/NamedScreen.kt) and [`EnvironmentScreen`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/EnvironmentScreen.kt) wrapper types to prevent needless journeys through `@Composable fun AndroidView()` and the `ComposeView` class. (https://github.com/square/workflow-kotlin/issues/546) -> Compose didn’t always link compositions in a view tree automatically. Until around late 2020, the Workflow infrastructure had to pass this composition link through its analagous [`ViewEnvironment`](https://github.com/square/workflow-kotlin/blob/v1.0.0-alpha18/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ViewEnvironment.kt), which prevented any `ViewFactory` from using `AndroidView` or `AbstractComposeView` itself. We submitted a [feature request](https://issuetracker.google.com/issues/156527485) to move this behavior into the core library. Fortunately, [it got accepted](https://android-review.googlesource.com/c/platform/frameworks/support/+/1347523/), and now all Compose/Android view integrations do this [automatically](https://android-review.googlesource.com/c/platform/frameworks/support/+/1564002). This is a great example of why this early experimentation was very helpful. - -However, because the Workflow modal infrastructure manages independent view trees (each `Dialog` hosts its own view tree), we need to make sure that compositions hosted inside modals are created as child compositions of any compositions enclosing the [`ModalContainer`](https://github.com/square/workflow-kotlin/blob/v1.0.0-alpha18/workflow-ui/modal-android/src/main/java/com/squareup/workflow1/ui/modal/ModalContainer.kt). This is one feature that has not yet been implemented in the experimental integration project, because the automatic linking of compositions is fairly recent. The proposed solution is described in the _Linking modal compositions_ section below. +## API Design -### Hosting +The following APIs will are packaged into two Maven artifacts. Most of them live in a core “Workflow-compose” module, and the preview tooling support is in a “Workflow-compose-tooling” module. -Hosting a Workflow runtime from a composition is not very interesting as far as our internal apps are concerned, because we have a few other layers of infrastructure at the root of our apps. For our use cases, we’re only allowing Compose to be used inside of the `ViewFactory` constructions specified above, so we don’t need to worry about how to host a Workflow runtime inside a composition for now. However, it is exciting to think about using Workflows in an app that is fully Compose-based, and even if we don’t use it internally, it may be useful for external consumers of the library. Details of the hosting API are specified in the _API Design_ section below. +### Core APIs -## API Design +---- -The following APIs will be packaged into two Maven artifacts. Most of them will live in a core “Workflow-compose” module, and the preview tooling support will live in a “Workflow-compose-tooling” module. +#### Opting in / Bootstrapping -Alternatively, it may also make sense to split the runtime/hosting APIs into a third module, since the main Workflow modules are split by core/runtime, and most Workflow code doesn’t need runtime stuff. The actual runtime code added for Compose support is quite small, but requires a transitive dependency on the Workflow-runtime module, so splitting the compose modules in kind would keep the transitive deps of non-runtime consumers cleaner. +Alas, we can't make this just work out of the box without you making a bootstrap call to put the key pieces in place. +Even if your own UI code is strictly built in Compose, the stock `BackStackScreen` and `BodyAndOverlaysScreen` types are still implemented only via classic `View` code. +You need to call `ViewEnvironment.withComposeInteropSupport()` somewhere near the top. +For example, here is how to do it with your `renderWorkflowIn()` call: -### Core APIs +```kotlin +private val viewEnvironment = ViewEnvironment.EMPTY.withComposeInteropSupport() + +renderWorkflowIn( + workflow = HelloWorkflow.mapRendering { + it.withEnvironment(viewEnvironment) + }, + scope = viewModelScope, + savedStateHandle = savedState, +) +``` ----- +#### Defining Compose-based UI factories -#### Defining Compose-based `ViewFactory`s +The most straightforward and common way to tie a `Screen` rendering type to a `@Composable` function is to implement [`ComposeScreen`](https://github.com/square/workflow-kotlin/blob/9bfd5119fabd0a3dfbc25bf7d93e52c7b31bb4cd/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt), the Compose-friendly analog to [`AndroidScreen`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt). ```kotlin -inline fun composedViewFactory( - noinline content: @Composable ( - rendering: RenderingT, - environment: ViewEnvironment - ) -> Unit -): ViewFactory + data class HelloScreen( + val message: String, + val onClick: () -> Unit + ) : ComposeScreen { + + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + Button(onClick) { + Text(message) + } + } + } ``` -This is the primary API that most feature developers would touch when combining Workflow and Compose. It’s a single builder function that takes a composable lambda that emits the UI for the given rendering type. The rendering and view environment are simply provided as parameters, and Compose’s machinery takes care of ensuring the UI is updated when a new rendering or view environment is available. +`ComposeScreen` is a convenience that automates creating a `ScreenComposableFactory` implementation responsible for expressing, say, `HelloScreen` instances by calling `HelloScreen.Content()`. + + +This `ScreenComposableFactory` interface is the lynchpin API combining Workflow and Compose. It’s basically a single builder function that takes a `@Composable` lambda that emits the UI for the given `Screen` rendering type. In Compose terms, the `Screen` acts as hoisted state for thre related `@Composable`. The rendering and view environment are simply provided as parameters, and Compose’s machinery takes care of ensuring the UI is updated when a new rendering or view environment is available. -Here’s an example of how it can be used: +Here’s an example of how `ScreenComposableFactory` can be used directly to keep a rendering type decoupled from the related Compose code: ```kotlin -val contactFactory = composedViewFactory { rendering, viewEnvironment -> +data class ContactScreen( + val name: String, + val phoneNumber: String +): Screen +``` +```kotlin +val contactUiFactory = ScreenComposableFactory { rendering, viewEnvironment -> Column { Text(rendering.name) Text(rendering.phoneNumber) } } -``` - -This inline function creates an instance of a special concrete `ViewFactory` type. This type is currently internal-only, but it may make sense to make it public to allow creating such view factories via subclassing to allow Dagger injection. Such a class would simply look like this: - -```kotlin -abstract class ComposeViewFactory : ViewFactory { - @Composable abstract fun Content( - rendering: RenderingT, - viewEnvironment: ViewEnvironment - ) +private val viewEnvironment = ViewEnvironment.EMPTY + + (ViewRegistry to ViewRegistry(contactUiFactory)) + .withComposeInteropSupport() - final override fun buildView(...) = ... -} +renderWorkflowIn( + workflow = HelloWorkflow.mapRendering { + it.withEnvironment(viewEnvironment) + }, + scope = viewModelScope, + savedStateHandle = savedState, +) ``` ---- -#### Delegating to a child `ViewFactory` from a composition +#### Delegating to a child UI factory from a composition Aka, `WorkflowViewStub` — Compose Edition! The idea of “view stub” is nonsense in Compose — there are no views! Instead, we simply provide a composable that takes a rendering and a view environment, and tries to display the rendering from the environment’s `ViewRegistry`. ```kotlin @Composable fun WorkflowRendering( - rendering: Any, + rendering: Screen, viewEnvironment: ViewEnvironment, modifier: Modifier = Modifier ) @@ -194,7 +224,12 @@ The `Modifier` parameter is also provided as it is idiomatic for composable func Here’s an example of how it could be used: ```kotlin -val contactFactory = composedViewFactory { rendering, viewEnvironment -> +data class ContactScreen( + val name: String, + val details: Screen +): Screen + +val contactUiFactory = ScreenComposableFactory { rendering, viewEnvironment -> Column { Text(rendering.name) @@ -207,65 +242,38 @@ val contactFactory = composedViewFactory { rendering, viewEnvironment -> } ``` ----- - -#### Linking modal compositions - -This API has not yet been written. The proposed shape is to create a special `ViewEnvironment` key that holds a value something like this in the core Android Workflow UI module: - -```kotlin -fun interface ViewRootConnector { - fun connectViewRoot( - containingView: View, - childRootView: View - ) -} - -``` - -When a container that creates new view trees, such as the view tree inside a dialog-based modal, initializes a new view root, it would be required to look for this element in the `ViewEnvironment` and, if found, call `connectViewRoot`. - -The Compose integration would then provide an implementation of this that would look up the composition context from the containing View’s tag and set it on the new child root view. - -The awkward part of this design is that apps that are using Workflow + Compose would need to ensure they provide this connector in their root `ViewEnvironment`s. One potential workaround for this would be for the main Workflow UI module to use reflection to wire this up automatically, if the compose Workflow module was available on the classpath. - --- -#### Previewing Compose-based `ViewFactory`s +#### Previewing Compose-based (and non-Compose!) UI Factories -Compose provides IDE support for [previewing composables](https://developer.android.com/jetpack/compose/tooling#preview) by annotating them with the `@Preview` annotation. Because previews are composed in a special environment in the IDE itself, they often cannot rely on the external context around the composable being set up as it would normally in a full app. For Workflow integration, it would be nice to be able to write preview functions for view factories. +Compose provides IDE support for [previewing composables](https://developer.android.com/jetpack/compose/tooling#preview) by annotating them with the `@Preview` annotation. Because previews are composed in a special environment in the IDE itself, they often cannot rely on the external context around the composable being set up as it would normally in a full app. For Workflow integration, we provide support to write preview functions for UI factories. -**This use case doesn’t just apply to composable view factories!** Because Workflow Compose supports mixing Android and Compose factories, we can preview _any_ `ViewFactory`, which means we could even use it to preview classic Android view factories, `LayoutRunner`s, etc. - -We don’t technically need any special work to support this. However, lots of view factories nest other renderings’ factories, so preview functions need to provide some bindings in the `ViewRegistry` to fake out those nested factories. To make this easier, we provide a composable function as an extension on `ViewFactory` that takes a rendering object for that factory and renders it, filling in visual placeholders for any calls to `WorkflowRendering`. +We don’t technically need any special work to support this. However, lots of view factories nest other renderings’ factories, so preview functions need to provide some bindings in the `ViewRegistry` to fake out those nested factories. To make this easier, we provide a composable function as an extension on `Screen` that takes a rendering object for that factory and renders it, filling in visual placeholders for any calls to `WorkflowRendering()`. ```kotlin -@Composable fun ViewFactory.Preview( - rendering: RenderingT, - modifier: Modifier = Modifier, - placeholderModifier: Modifier = Modifier, - viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null +@Composable fun Screen.Preview( + modifier: Modifier = Modifier, + placeholderModifier: Modifier = Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null ) ``` +**This doesn’t just apply to composable UI!** +You can call `Preview()` on any `Screen` that has been bound to UI code, regardless of how that UI code is implemented. -The function takes some additional optional parameters that allow customizing how placeholders are displayed, and lets you add more stuff to the ViewEnvironment if your factory reads certain values that you’d like to control in the preview. - -We can also provide a version of this method that’s an extension on [`AndroidViewRendering`](https://github.com/square/workflow-kotlin/blob/v1.0.0-alpha18/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidViewRendering.kt) so you can `@Preview` your renderings! +The function takes some additional optional parameters that allow customizing how placeholders are displayed, and lets you add more stuff to the `ViewEnvironment` if your factory reads certain values that you’d like to control in the preview. Here’s an example of a contact card UI that uses a nested `WorkflowRendering` that is filled with a placeholder: ```kotlin @Preview @Composable fun ContactViewFactoryPreview() { - contactViewFactory.preview( - ContactRendering( - name = "Dim Tonnelly", - details = ContactDetailsRendering( - phoneNumber = "555-555-5555", - address = "1234 Apgar Lane" - ) + ContactScreen( + name = "Dim Tonnelly", + details = ContactDetailsRendering( + phoneNumber = "555-555-5555", + address = "1234 Apgar Lane" ) - ) + ).Preview() } ``` @@ -285,11 +293,11 @@ Workflow.renderAsState( ): State ``` -It’s parameters roughly match those of `renderWorkflowIn`: it takes the props for the root Workflow, an optional list of interceptors, and a suspending callback for processing the root Workflow’s outputs. It returns the root Workflow’s rendering value via a `State` object (basically Compose’s analog to [`BehaviorRelay`](https://github.com/JakeWharton/RxRelay/blob/rxrelay-3.0.1/src/main/java/com/jakewharton/rxrelay3/BehaviorRelay.java)). +Its parameters roughly match those of `renderWorkflowIn`: it takes the props for the root Workflow, an optional list of interceptors, and a suspending callback for processing the root Workflow’s outputs. It returns the root Workflow’s rendering value via a `State` object (basically Compose’s analog to [`StateFlow`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/)). This function initializes and starts an instance of the Workflow runtime when it enters a composition. It uses the composition’s implicit coroutine context to host the runtime and execute the output callback. It automatically wires up [`Snapshot`](https://github.com/square/workflow-kotlin/blob/v1.0.0-alpha18/workflow-core/src/main/java/com/squareup/workflow1/Snapshot.kt) saving and restoring using Compose’s [`SaveableStateRegistry` mechanism](https://developer.android.com/jetpack/compose/state) (ie using `rememberSaveable`). -Because this function binds the Workflow runtime to the lifetime of the composition, it is best-suited for use in apps that disable restarting activities for UI-related configuration changes. That said, because it automatically saves and restores the Workflow tree’s state via snapshots, it would still work in those cases, just not as efficiently. +Because this function binds the Workflow runtime to the lifetime of the composition, it is best suited for use in apps that disable restarting activities for UI-related configuration changes (which really is the best way to build a Compose-first application). That said, because it automatically saves and restores the Workflow tree’s state via snapshots, it would still work in those cases, just not as efficiently. Note that this function does not have anything to do with UI itself - it can even be placed in a module that has no dependencies on Compose UI artifacts and only the Compose runtime. If the root Workflow’s rendering needs to be displayed as Android UI, it can be easily done via the `WorkflowRendering` composable function. @@ -309,117 +317,6 @@ Here’s an example: WorkflowRendering(rootRendering, viewEnvironment) } ``` ----- - -#### Controlling the Lifecycle of a container - -**This component is only required if `SavedStateRegistry` support is implemented via classic view state.** - -We introduce an interface called `WorkflowLifecycleOwner` that containers must use to install a `ViewTreeLifecycleOwner` on their immediate child views, and then must later call `destroyOnDetach` on when that view is about to either go away or be replaced with a new view from the `ViewFactory`. - -```kotlin -public interface WorkflowLifecycleOwner : LifecycleOwner { - - public fun destroyOnDetach() - - public companion object { - - public fun installOn( - view: View, - findParentLifecycle: () -> Lifecycle? = …, - lifecycleRatchet: Lifecycle = AlwaysResumedLifecycle - ) - - public fun get(view: View): WorkflowLifecycleOwner? - - } -} -``` - -The ratchet parameter allows a container to hold the lifecycle at a particular state, e.g. to support the saved state registry. Containers which need to hold the lifecycle at a particular state can do so by passing a ratchet and only advancing it once the state has been restored. - ----- - -### Optional APIs - -The following APIs might be cool, but they’re not required, and while they were built experimentally we might not want to ship them in production at this time. - ----- - -#### Inline composable renderings - -One use case that has come up for both Android and iOS Workflows is to define rendering types which know how to render themselves implicitly. In Workflow UI Android, rendering types can implement the `AndroidViewRendering` interface to specify their own view factories directly, instead of requiring their view factories to be registered explicitly in the `ViewRegistry`. - -This feature presents an interesting potential construct for the compose integration: Workflows that are defined as composable functions which emit their own UI directly instead of going through the render —> rendering —> `ViewFactory` steps. Here’s what an API for defining such Workflows could look like: - -```kotlin -abstract class ComposeWorkflow : - Workflow { - - @Composable abstract fun render( - props: PropsT, - outputSink: Sink, - viewEnvironment: ViewEnvironment - ) -} - -class ComposeRendering : AndroidViewRendering -``` - -This render method takes a `PropsT` just like a traditional Workflow, but that’s where the similarities end. It doesn’t get any state value (but that doesn’t mean it is stateless - see below). It does not get a `RenderContext`, which means it cannot render child Workflows or run workers. It can however still delegate to other view factories via the `WorkflowRendering` composable. It does get access to a `Sink`, although it’s not the usual `actionSink` - it does not accept arbitrary `WorkflowAction`s, because it doesn’t need to due to the lack of Workflow state. The sink simply accepts `OutputT` values directly, which are effectively all “rendering events”. The render method gets called not as part of the Workflow render pass but rather as part of the view update pass that occurs once the Workflow runtime has emitted a new rendering tree. This is why it can’t render child Workflows - it gets invoked too late in the pipeline. Its rendering type is an opaque, final concrete class that has only one possible use: to be rendered via a `WorkflowViewStub` or the `WorkflowRendering` composable. - -Such a Workflow may be stateful, although not in the usual sense: it does not actually store any state in the Workflow tree itself. Instead, it can use Compose’s memoization facility (ie the `remember` function) to store “view” state in the composition, or perhaps even the multiple compositions, into which it’s composed. - -The distinction that any state managed by Workflows defined this way is “_view_ state” is important. While it might look like Workflow state because it’s inlined into the definition of the Workflow itself, such state is owned by the view layer and not the Workflow layer. Consider that a single Workflow rendering can potentially be displayed multiple times in different places in the UI - in which case any state required by the rendering’s UI layer will be duplicated and managed separately by each occurrence. - -Similarly, while such Workflows cannot run Workers or Workflow side effects, they may perform long-running and potentially concurrent tasks that are scoped to their composition by using the standard Compose effect APIs, just like any composable. - -These Workflows do not define their own rendering types, and thus do not have anywhere to define rendering event handler functions. Instead, they can send outputs to their parent workflows directly from composable event callbacks via the `outputSink` parameter. - -These workflows can only be leaf workflows since they can’t render children. However, they may be very convenient in modules that already mix their `Workflow` and `ViewFactory` definitions in the same module and want to factor out workflows for self-contained components. - -Here’s an example of how it could be used: - -```kotlin -// Child Workflow -object ContactWorkflow : ComposeWorkflow< - Contact, // PropsT - Output // OutputT ->() { - - enum class Output { - CLICKED, DELETED - } - - @Composable override fun render( - props: Contact, - outputSink: Sink, - viewEnvironment: ViewEnvironment - ) { - ListItem( - primary = { Text(props.name) }, - secondary = { Text(props.phoneNumber) }, - modifier = Modifier - .clickable { outputSink.send(CLICKED) } - .swipeToDismissable { outputSink.send(DELETED) } - ) - } -} - -// Parent Workflow -class ContactList : StatelessWorkflow<...> { - override fun initialState(...) = ... - - override fun render(...) = ListRendering( - contactRows = props.contacts.map { contact -> - context.renderChild( - props = contact, - Workflow = ContactWorkflow - ) { output -> ... } - } - ) -} -``` ---- diff --git a/workflow-ui/compose/api/compose.api b/workflow-ui/compose/api/compose.api index db6a2e05e0..c5c72f7110 100644 --- a/workflow-ui/compose/api/compose.api +++ b/workflow-ui/compose/api/compose.api @@ -1,41 +1,74 @@ -public abstract interface class com/squareup/workflow1/ui/compose/ComposeScreen : com/squareup/workflow1/ui/AndroidScreen { - public abstract fun Content (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/runtime/Composer;I)V - public abstract fun getViewFactory ()Lcom/squareup/workflow1/ui/ScreenViewFactory; +public final class com/squareup/workflow1/ui/compose/ComposableSingletons$ScreenComposableFactoryFinderKt { + public static final field INSTANCE Lcom/squareup/workflow1/ui/compose/ComposableSingletons$ScreenComposableFactoryFinderKt; + public static field lambda-1 Lkotlin/jvm/functions/Function4; + public static field lambda-2 Lkotlin/jvm/functions/Function4; + public static field lambda-3 Lkotlin/jvm/functions/Function4; + public fun ()V + public final fun getLambda-1$wf1_compose ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-2$wf1_compose ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-3$wf1_compose ()Lkotlin/jvm/functions/Function4; } -public final class com/squareup/workflow1/ui/compose/ComposeScreen$DefaultImpls { - public static fun getViewFactory (Lcom/squareup/workflow1/ui/compose/ComposeScreen;)Lcom/squareup/workflow1/ui/ScreenViewFactory; +public abstract interface class com/squareup/workflow1/ui/compose/ComposeScreen : com/squareup/workflow1/ui/Screen { + public abstract fun Content (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/runtime/Composer;I)V } public final class com/squareup/workflow1/ui/compose/ComposeScreenKt { public static final fun ComposeScreen (Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/compose/ComposeScreen; } -public abstract class com/squareup/workflow1/ui/compose/ComposeScreenViewFactory : com/squareup/workflow1/ui/ScreenViewFactory { - public static final field $stable I - public fun ()V +public final class com/squareup/workflow1/ui/compose/CompositionRootKt { + public static final fun withCompositionRoot (Lcom/squareup/workflow1/ui/ViewEnvironment;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/ViewEnvironment; + public static final fun withCompositionRoot (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder; +} + +public final class com/squareup/workflow1/ui/compose/RenderAsStateKt { + public static final fun renderAsState (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/util/List;Lkotlinx/coroutines/CoroutineScope;Ljava/util/Set;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public abstract interface class com/squareup/workflow1/ui/compose/ScreenComposableFactory : com/squareup/workflow1/ui/ViewRegistry$Entry { public abstract fun Content (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/runtime/Composer;I)V - public final fun buildView (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;)Lcom/squareup/workflow1/ui/ScreenViewHolder; - public final fun getKey ()Lcom/squareup/workflow1/ui/ViewRegistry$Key; + public abstract fun getKey ()Lcom/squareup/workflow1/ui/ViewRegistry$Key; + public abstract fun getType ()Lkotlin/reflect/KClass; } -public final class com/squareup/workflow1/ui/compose/ComposeScreenViewFactoryKt { - public static final fun composeScreenViewFactory (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function4;)Lcom/squareup/workflow1/ui/ScreenViewFactory; +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactory$DefaultImpls { + public static fun getKey (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory;)Lcom/squareup/workflow1/ui/ViewRegistry$Key; } -public final class com/squareup/workflow1/ui/compose/CompositionRootKt { - public static final fun withCompositionRoot (Lcom/squareup/workflow1/ui/ScreenViewFactoryFinder;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/ScreenViewFactoryFinder; - public static final fun withCompositionRoot (Lcom/squareup/workflow1/ui/ViewEnvironment;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/ViewEnvironment; +public abstract interface class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder { + public static final field Companion Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder$Companion; + public abstract fun getComposableFactoryForRendering (Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; } -public final class com/squareup/workflow1/ui/compose/RenderAsStateKt { - public static final fun renderAsState (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/util/List;Lkotlinx/coroutines/CoroutineScope;Ljava/util/Set;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder$Companion : com/squareup/workflow1/ui/ViewEnvironmentKey { + public fun getDefault ()Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder; + public synthetic fun getDefault ()Ljava/lang/Object; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder$DefaultImpls { + public static fun getComposableFactoryForRendering (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder;Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinderKt { + public static final fun requireComposableFactoryForRendering (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder;Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryKt { + public static final fun ScreenComposableFactory (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function4;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; + public static final fun asComposableFactory (Lcom/squareup/workflow1/ui/ScreenViewFactory;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; + public static final fun asViewFactory (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory;)Lcom/squareup/workflow1/ui/ScreenViewFactory; + public static final fun toComposableFactory (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; } public final class com/squareup/workflow1/ui/compose/TextControllerAsMutableStateKt { public static final fun asMutableState (Lcom/squareup/workflow1/ui/TextController;Landroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/MutableState; } +public final class com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupportKt { + public static final fun withComposeInteropSupport (Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/ViewEnvironment; +} + public final class com/squareup/workflow1/ui/compose/WorkflowRenderingKt { public static final fun WorkflowRendering (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeScreenViewFactoryTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt similarity index 85% rename from workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeScreenViewFactoryTest.kt rename to workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt index 27f7ab2cca..23402346fc 100644 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeScreenViewFactoryTest.kt +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt @@ -33,7 +33,7 @@ import org.junit.runner.RunWith @OptIn(WorkflowUiExperimentalApi::class) @RunWith(AndroidJUnit4::class) -internal class ComposeScreenViewFactoryTest { +internal class ScreenComposableFactoryTest { private val composeRule = createComposeRule() @@ -44,10 +44,11 @@ internal class ComposeScreenViewFactoryTest { .around(IdlingDispatcherRule) @Test fun showsComposeContent() { - val viewFactory = composeScreenViewFactory { _, _ -> + val viewFactory = ScreenComposableFactory { _, _ -> BasicText("Hello, world!") } - val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(viewFactory) + val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(viewFactory)) + .withComposeInteropSupport() composeRule.setContent { AndroidView(::RootView) { @@ -59,10 +60,11 @@ internal class ComposeScreenViewFactoryTest { } @Test fun getsRenderingUpdates() { - val viewFactory = composeScreenViewFactory { rendering, _ -> + val viewFactory = ScreenComposableFactory { rendering, _ -> BasicText(rendering.text, Modifier.testTag("text")) } - val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(viewFactory) + val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(viewFactory)) + .withComposeInteropSupport() var rendering by mutableStateOf(TestRendering("hello")) composeRule.setContent { @@ -82,13 +84,14 @@ internal class ComposeScreenViewFactoryTest { override val default: String get() = error("No default") } - val viewFactory = composeScreenViewFactory { _, environment -> + val viewFactory = ScreenComposableFactory { _, environment -> val text = environment[testEnvironmentKey] BasicText(text, Modifier.testTag("text")) } val viewRegistry = ViewRegistry(viewFactory) var viewEnvironment by mutableStateOf( - ViewEnvironment.EMPTY + viewRegistry + (testEnvironmentKey to "hello") + (ViewEnvironment.EMPTY + viewRegistry + (testEnvironmentKey to "hello")) + .withComposeInteropSupport() ) composeRule.setContent { @@ -112,6 +115,7 @@ internal class ComposeScreenViewFactoryTest { content() } } + .withComposeInteropSupport() composeRule.setContent { AndroidView(::RootView) { @@ -137,7 +141,7 @@ internal class ComposeScreenViewFactoryTest { private data class TestRendering(val text: String = "") : Screen private companion object { - val TestFactory = composeScreenViewFactory { rendering, _ -> + val TestFactory = ScreenComposableFactory { rendering, _ -> BasicText(rendering.text) } } diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt index 107ed58f56..bd12f84db2 100644 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertIsDisplayed @@ -63,11 +64,13 @@ import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewEnvironmentKey import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule import com.squareup.workflow1.ui.plus +import com.squareup.workflow1.ui.withEnvironment import leakcanary.DetectLeaksAfterTestSuccess import org.hamcrest.Description import org.hamcrest.TypeSafeMatcher @@ -94,12 +97,12 @@ internal class WorkflowRenderingTest { ) : Screen val registry1 = ViewRegistry( - composeScreenViewFactory { rendering, _ -> + ScreenComposableFactory { rendering, _ -> BasicText(rendering.text) } ) val registry2 = ViewRegistry( - composeScreenViewFactory { rendering, _ -> + ScreenComposableFactory { rendering, _ -> BasicText(rendering.text.reversed()) } ) @@ -115,33 +118,10 @@ internal class WorkflowRenderingTest { composeRule.onNodeWithText("olleh").assertDoesNotExist() } - /** - * Ensures we match the behavior of WorkflowViewStub and other containers, which only check for - * a new factory when a new rendering is incompatible with the current one. - */ - @Test fun doesNotRecompose_whenAndroidViewRendering_factoryChanged() { - data class ShiftyRendering(val whichFactory: Boolean) : AndroidScreen { - override val viewFactory: ScreenViewFactory = when (whichFactory) { - true -> composeScreenViewFactory { _, _ -> BasicText("one") } - false -> composeScreenViewFactory { _, _ -> BasicText("two") } - } - } - - var rendering by mutableStateOf(ShiftyRendering(true)) - - composeRule.setContent { - WorkflowRendering(rendering, ViewEnvironment.EMPTY) - } - - composeRule.onNodeWithText("one").assertIsDisplayed() - rendering = ShiftyRendering(false) - composeRule.onNodeWithText("one").assertIsDisplayed() - } - @Test fun wrapsFactoryWithRoot_whenAlreadyInComposition() { data class TestRendering(val text: String) : Screen - val testFactory = composeScreenViewFactory { rendering, _ -> + val testFactory = ScreenComposableFactory { rendering, _ -> BasicText(rendering.text) } val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(testFactory)) @@ -164,7 +144,7 @@ internal class WorkflowRenderingTest { val wrapperText = mutableStateOf("two") composeRule.setContent { - WorkflowRendering(LegacyViewRendering(wrapperText.value), ViewEnvironment.EMPTY) + WorkflowRendering(LegacyViewRendering(wrapperText.value), env) } onView(withText("two")).check(matches(isDisplayed())) @@ -178,7 +158,7 @@ internal class WorkflowRenderingTest { composeRule.setContent { val rendering = NamedScreen(LegacyViewRendering(wrapperText.value), "fnord") - WorkflowRendering(rendering, ViewEnvironment.EMPTY) + WorkflowRendering(rendering, env) } onView(withText("two")).check(matches(isDisplayed())) @@ -186,10 +166,55 @@ internal class WorkflowRenderingTest { onView(withText("OWT")).check(matches(isDisplayed())) } + @Test fun namedScreenStaysInTheSameComposeView() { + composeRule.setContent { + val outer = LocalView.current + + WorkflowRendering( + viewEnvironment = env, + rendering = NamedScreen( + name = "fnord", + content = ComposeScreen { + val inner = LocalView.current + assertThat(inner).isSameInstanceAs(outer) + + BasicText("hello", Modifier.testTag("tag")) + } + ) + ) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("hello") + } + + @Test fun environmentScreenStaysInTheSameComposeView() { + val someKey = object : ViewEnvironmentKey() { + override val default = "default" + } + + composeRule.setContent { + val outer = LocalView.current + + WorkflowRendering( + viewEnvironment = env, + rendering = ComposeScreen { environment -> + val inner = LocalView.current + assertThat(inner).isSameInstanceAs(outer) + + BasicText(environment[someKey], Modifier.testTag("tag")) + }.withEnvironment((someKey to "fnord")) + ) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("fnord") + } + @Test fun destroysChildLifecycle_fromCompose_whenIncompatibleRendering() { val lifecycleEvents = mutableListOf() - class LifecycleRecorder : ComposableRendering { + class LifecycleRecorder : ComposableRendering { @Composable override fun Content(viewEnvironment: ViewEnvironment) { val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { @@ -206,13 +231,13 @@ internal class WorkflowRenderingTest { } } - class EmptyRendering : ComposableRendering { + class EmptyRendering : ComposableRendering { @Composable override fun Content(viewEnvironment: ViewEnvironment) {} } var rendering: Screen by mutableStateOf(LifecycleRecorder()) composeRule.setContent { - WorkflowRendering(rendering, ViewEnvironment.EMPTY) + WorkflowRendering(rendering, env) } composeRule.runOnIdle { @@ -248,13 +273,13 @@ internal class WorkflowRenderingTest { } } - class EmptyRendering : ComposableRendering { + class EmptyRendering : ComposableRendering { @Composable override fun Content(viewEnvironment: ViewEnvironment) {} } var rendering: Screen by mutableStateOf(LifecycleRecorder()) composeRule.setContent { - WorkflowRendering(rendering, ViewEnvironment.EMPTY) + WorkflowRendering(rendering, env) } composeRule.runOnIdle { @@ -279,7 +304,7 @@ internal class WorkflowRenderingTest { composeRule.setContent { CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { - WorkflowRendering(LifecycleRecorder(states), ViewEnvironment.EMPTY) + WorkflowRendering(LifecycleRecorder(states), env) } } @@ -327,7 +352,7 @@ internal class WorkflowRenderingTest { composeRule.setContent { CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { - WorkflowRendering(LifecycleRecorder(states), ViewEnvironment.EMPTY) + WorkflowRendering(LifecycleRecorder(states), env) } } @@ -337,7 +362,7 @@ internal class WorkflowRenderingTest { } @Test fun appliesModifierToComposableContent() { - class Rendering : ComposableRendering { + class Rendering : ComposableRendering { @Composable override fun Content(viewEnvironment: ViewEnvironment) { Box( Modifier @@ -350,7 +375,7 @@ internal class WorkflowRenderingTest { composeRule.setContent { WorkflowRendering( Rendering(), - ViewEnvironment.EMPTY, + env, Modifier.size(width = 42.dp, height = 43.dp) ) } @@ -361,7 +386,7 @@ internal class WorkflowRenderingTest { } @Test fun propagatesMinConstraints() { - class Rendering : ComposableRendering { + class Rendering : ComposableRendering { @Composable override fun Content(viewEnvironment: ViewEnvironment) { Box(Modifier.testTag("box")) } @@ -370,7 +395,7 @@ internal class WorkflowRenderingTest { composeRule.setContent { WorkflowRendering( Rendering(), - ViewEnvironment.EMPTY, + env, Modifier.sizeIn(minWidth = 42.dp, minHeight = 43.dp) ) } @@ -397,7 +422,7 @@ internal class WorkflowRenderingTest { with(LocalDensity.current) { WorkflowRendering( LegacyRendering(viewId), - ViewEnvironment.EMPTY, + env, Modifier.size(42.toDp(), 43.toDp()) ) } @@ -411,7 +436,7 @@ internal class WorkflowRenderingTest { class Rendering( override val compatibilityKey: String - ) : ComposableRendering, Compatible { + ) : ComposableRendering, Compatible { @Composable override fun Content(viewEnvironment: ViewEnvironment) { var counter by rememberSaveable { mutableStateOf(0) } Column { @@ -432,7 +457,7 @@ internal class WorkflowRenderingTest { var key by mutableStateOf("one") composeRule.setContent { - WorkflowRendering(Rendering(key), ViewEnvironment.EMPTY) + WorkflowRendering(Rendering(key), env) } composeRule.onNodeWithTag("tag") @@ -461,7 +486,7 @@ internal class WorkflowRenderingTest { @Test fun doesNotSkipPreviousContentWhenCompatible() { var disposeCount = 0 - class Rendering(val text: String) : ComposableRendering { + class Rendering(val text: String) : ComposableRendering { @Composable override fun Content(viewEnvironment: ViewEnvironment) { var counter by rememberSaveable { mutableStateOf(0) } Column { @@ -482,7 +507,7 @@ internal class WorkflowRenderingTest { var text by mutableStateOf("one") composeRule.setContent { - WorkflowRendering(Rendering(text), ViewEnvironment.EMPTY) + WorkflowRendering(Rendering(text), env) } composeRule.onNodeWithTag("tag") @@ -517,7 +542,7 @@ internal class WorkflowRenderingTest { private class LifecycleRecorder( // For some reason, if we just capture the states val, it is null in the composable. private val states: MutableList - ) : ComposableRendering { + ) : ComposableRendering { @Composable override fun Content(viewEnvironment: ViewEnvironment) { val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { @@ -535,25 +560,40 @@ internal class WorkflowRenderingTest { } } - private interface ComposableRendering> : - AndroidScreen { - - /** - * It is significant that this returns a new instance on every call, since we can't rely on real - * implementations in the wild to reuse the same factory instance across rendering instances. - */ - override val viewFactory: ScreenViewFactory - get() = object : ComposeScreenViewFactory>() { - override val type: KClass> = ComposableRendering::class - - @Composable override fun Content( - rendering: ComposableRendering<*>, - viewEnvironment: ViewEnvironment - ) { - rendering.Content(viewEnvironment) + /** + * It is significant that this returns a new instance on every call, since we can't rely on real + * implementations in the wild to reuse the same factory instance across rendering instances. + */ + private object InefficientComposableFinder : ScreenComposableFactoryFinder { + override fun getComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenComposableFactory? { + return if (rendering is ComposableRendering) { + object : ScreenComposableFactory { + override val type: KClass get() = error("whatever") + + @Composable override fun Content( + rendering: ScreenT, + environment: ViewEnvironment + ) { + (rendering as ComposableRendering).Content(environment) + } } + } else { + super.getComposableFactoryForRendering( + environment, + rendering + ) } + } + } + + private val env = + (ViewEnvironment.EMPTY + (ScreenComposableFactoryFinder to InefficientComposableFinder)) + .withComposeInteropSupport() + private interface ComposableRendering : Screen { @Composable fun Content(viewEnvironment: ViewEnvironment) } diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt index 0d5593074a..836f518ad6 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt @@ -2,47 +2,71 @@ package com.squareup.workflow1.ui.compose import androidx.compose.runtime.Composable import com.squareup.workflow1.ui.AndroidScreen -import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import kotlin.reflect.KClass /** * Interface implemented by a rendering class to allow it to drive a composable UI via an - * appropriate [ComposeScreenViewFactory] implementation, by simply overriding the [Content] method. + * appropriate [ScreenComposableFactory] implementation, by simply overriding the [Content] method. * This is the compose analog to [AndroidScreen]. * + * **NB**: A Workflow app that relies on Compose must call [withComposeInteropSupport] + * on its top-level [ViewEnvironment]. See that function for details. + * * Note that unlike most workflow view functions, [Content] does not take the rendering as a * parameter. Instead, the rendering is the receiver, i.e. the current value of `this`. * * Example: * - * ``` - * @OptIn(WorkflowUiExperimentalApi::class) - * data class HelloView( - * val message: String, - * val onClick: () -> Unit - * ) : ComposeScreen { + * @OptIn(WorkflowUiExperimentalApi::class) + * data class HelloScreen( + * val message: String, + * val onClick: () -> Unit + * ) : ComposeScreen { * - * @Composable override fun Content(viewEnvironment: ViewEnvironment) { - * Button(onClick) { - * Text(message) + * @Composable override fun Content(viewEnvironment: ViewEnvironment) { + * Button(onClick) { + * Text(message) + * } + * } * } - * } - * } - * ``` * * This is the simplest way to bridge the gap between your workflows and the UI, but using it - * requires your workflows code to reside in Android modules, instead of pure Kotlin. If this is a - * problem, or you need more flexibility for any other reason, you can use [ViewRegistry] to bind - * your renderings to [ComposeScreenViewFactory] implementations at runtime. + * requires your workflows code to reside in Android modules and depend upon the Compose runtime, + * instead of being pure Kotlin. If this is a problem, or you need more flexibility for any other + * reason, you can use [ViewRegistry] to bind your renderings to [ScreenComposableFactory] + * implementations at runtime. + * + * ## Nesting child renderings + * + * Workflows can render other workflows, and renderings from one workflow can contain renderings + * from other workflows. These renderings may all be bound to their own UI factories. + * A classic [ScreenViewFactory][com.squareup.workflow1.ui.ScreenViewFactory] can + * use [WorkflowViewStub][com.squareup.workflow1.ui.WorkflowViewStub] to recursively show nested + * renderings. + * + * Compose-based UI may also show nested renderings. Doing so is as simple + * as calling [WorkflowRendering] and passing in the nested rendering. + * See the kdoc on that function for an example. + * + * Nested renderings will have access to any + * [composition locals][androidx.compose.runtime.CompositionLocal] defined in outer composable, even + * if there are legacy views in between them, as long as the [ViewEnvironment] is propagated + * continuously between the two factories. + * + * ## Initializing Compose context (Theming) + * + * Often all the [ScreenComposableFactory] factories in an app need to share some context – + * for example, certain composition locals need to be provided, such as `MaterialTheme`. + * To configure this shared context, call [withCompositionRoot] on your top-level [ViewEnvironment]. + * The first time a [ScreenComposableFactory] is used to show a rendering, its [Content] function + * will be wrapped with the [CompositionRoot]. See the documentation on [CompositionRoot] for + * more information. */ @WorkflowUiExperimentalApi -public interface ComposeScreen : AndroidScreen { - - /** Don't override this, override [Content] instead. */ - override val viewFactory: ScreenViewFactory get() = Companion +public interface ComposeScreen : Screen { /** * The composable content of this rendering. This method will be called with the current rendering @@ -50,21 +74,6 @@ public interface ComposeScreen : AndroidScreen { * changes. */ @Composable public fun Content(viewEnvironment: ViewEnvironment) - - private companion object : ComposeScreenViewFactory() { - /** - * Just returns [ComposeScreen]'s class, since this factory isn't for using with a view - * registry it doesn't matter. - */ - override val type: KClass = ComposeScreen::class - - @Composable override fun Content( - rendering: ComposeScreen, - viewEnvironment: ViewEnvironment - ) { - rendering.Content(viewEnvironment) - } - } } /** diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreenViewFactory.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreenViewFactory.kt deleted file mode 100644 index 1298ae40bd..0000000000 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreenViewFactory.kt +++ /dev/null @@ -1,144 +0,0 @@ -// See https://youtrack.jetbrains.com/issue/KT-31734 -@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") - -package com.squareup.workflow1.ui.compose - -import android.content.Context -import android.view.ViewGroup -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.ComposeView -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactory -import com.squareup.workflow1.ui.ScreenViewHolder -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewRegistry.Key -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import kotlin.reflect.KClass - -/** - * Creates a [ScreenViewFactory] that uses a [Composable] function to display the rendering. - * - * Simple usage: - * - * ``` - * val FooViewFactory = composeScreenViewFactory { rendering, _ -> - * Text(rendering.message) - * } - * - * … - * - * val viewRegistry = ViewRegistry(FooViewFactory, …) - * ``` - * - * If you need to write a class instead of a function, for example to support dependency injection, - * see [ComposeScreenViewFactory]. - * - * For more details about how to write composable view factories, see [ComposeScreenViewFactory]. - */ -@WorkflowUiExperimentalApi -public inline fun composeScreenViewFactory( - noinline content: @Composable ( - rendering: RenderingT, - environment: ViewEnvironment - ) -> Unit -): ScreenViewFactory = composeScreenViewFactory(RenderingT::class, content) - -@PublishedApi -@WorkflowUiExperimentalApi -internal fun composeScreenViewFactory( - type: KClass, - content: @Composable ( - rendering: RenderingT, - environment: ViewEnvironment - ) -> Unit -): ScreenViewFactory = object : ComposeScreenViewFactory() { - override val type: KClass = type - - @Composable override fun Content( - rendering: RenderingT, - viewEnvironment: ViewEnvironment - ) { - content(rendering, viewEnvironment) - } -} - -/** - * A [ScreenViewFactory] that uses a [Composable] function to display the rendering. It is the - * Compose-based analogue of [ScreenViewRunner][com.squareup.workflow1.ui.ScreenViewRunner]. - * - * Simple usage: - * - * ``` - * class FooViewFactory : ComposeScreenViewFactory() { - * override val type = FooScreen::class - * - * @Composable override fun Content( - * rendering: FooScreen, - * viewEnvironment: ViewEnvironment - * ) { - * Text(rendering.message) - * } - * } - * - * … - * - * val viewRegistry = ViewRegistry(FooViewFactory, …) - * ``` - * - * ## Nesting child renderings - * - * Workflows can render other workflows, and renderings from one workflow can contain renderings - * from other workflows. These renderings may all be bound to their own [ScreenViewFactory]s. - * Regular [ScreenViewFactory]s and [ScreenViewRunner][com.squareup.workflow1.ui.ScreenViewRunner]s - * use [WorkflowViewStub][com.squareup.workflow1.ui.WorkflowViewStub] to recursively show nested - * renderings using the [ViewRegistry][com.squareup.workflow1.ui.ViewRegistry]. - * - * View factories defined using this function may also show nested renderings. Doing so is as simple - * as calling [WorkflowRendering] and passing in the nested rendering. See the kdoc on that function - * for an example. - * - * Nested renderings will have access to any - * [composition locals][androidx.compose.runtime.CompositionLocal] defined in outer composable, even - * if there are legacy views in between them, as long as the [ViewEnvironment] is propagated - * continuously between the two factories. - * - * ## Initializing Compose context - * - * Often all the [composeScreenViewFactory] factories in an app need to share some context – - * for example, certain composition locals need to be provided, such as `MaterialTheme`. - * To configure this shared context, call [withCompositionRoot] on your top-level [ViewEnvironment]. - * The first time a [composeScreenViewFactory] is used to show a rendering, its [Content] function will - * be wrapped with the [CompositionRoot]. See the documentation on [CompositionRoot] for - * more information. - */ -@WorkflowUiExperimentalApi -public abstract class ComposeScreenViewFactory : - ScreenViewFactory { - - final override val key: Key> - get() = Key(type, ComposeScreenViewFactory::class) - - /** - * The composable content of this [ScreenViewFactory]. This method will be called - * any time [rendering] or [viewEnvironment] change. It is the Compose-based analogue of - * [ScreenViewRunner.showRendering][com.squareup.workflow1.ui.ScreenViewRunner.showRendering]. - */ - @Composable public abstract fun Content( - rendering: RenderingT, - viewEnvironment: ViewEnvironment - ) - - final override fun buildView( - initialRendering: RenderingT, - initialEnvironment: ViewEnvironment, - context: Context, - container: ViewGroup? - ): ScreenViewHolder { - val view = ComposeView(context) - return ScreenViewHolder(initialEnvironment, view) { rendering, environment -> - // Update the state whenever a new rendering is emitted. - // This lambda will be executed synchronously before ScreenViewHolder.show returns. - view.setContent { Content(rendering, environment) } - } - } -} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/CompositionRoot.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/CompositionRoot.kt index 07f65424d1..1902c7f9fb 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/CompositionRoot.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/CompositionRoot.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewFactoryFinder import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi @@ -20,44 +19,45 @@ private val LocalHasViewFactoryRootBeenApplied = staticCompositionLocalOf { fals /** * A composable function that will be used to wrap the first (highest-level) - * [composeScreenViewFactory] view factory in a composition. This can be used to setup any + * [ScreenComposableFactory] view factory in a composition. This can be used to setup any * [composition locals][androidx.compose.runtime.CompositionLocal] that all - * [composeScreenViewFactory] factories need access to, such as UI themes. + * [ScreenComposableFactory] factories need access to, such as UI themes. * - * This function will called once, to wrap the _highest-level_ [composeScreenViewFactory] - * in the tree. However, composition locals are propagated down to child [composeScreenViewFactory] - * compositions, so any locals provided here will be available in _all_ [composeScreenViewFactory] + * This function will called once, to wrap the _highest-level_ [ScreenComposableFactory] + * in the tree. However, composition locals are propagated down to child [ScreenComposableFactory] + * compositions, so any locals provided here will be available in _all_ [ScreenComposableFactory] * compositions. */ public typealias CompositionRoot = @Composable (content: @Composable () -> Unit) -> Unit /** * Convenience function for applying a [CompositionRoot] to this [ViewEnvironment]'s - * [ScreenViewFactoryFinder]. See [ScreenViewFactoryFinder.withCompositionRoot]. + * [ScreenComposableFactoryFinder]. See [ScreenComposableFactoryFinder.withCompositionRoot]. */ @WorkflowUiExperimentalApi public fun ViewEnvironment.withCompositionRoot(root: CompositionRoot): ViewEnvironment { return this + - (ScreenViewFactoryFinder to this[ScreenViewFactoryFinder].withCompositionRoot(root)) + (ScreenComposableFactoryFinder to this[ScreenComposableFactoryFinder].withCompositionRoot(root)) } /** - * Returns a [ScreenViewFactoryFinder] that ensures that any [composeScreenViewFactory] + * Returns a [ScreenViewFactoryFinder] that ensures that any [ScreenComposableFactory] * factories registered in this registry will be wrapped exactly once with a [CompositionRoot] * wrapper. See [CompositionRoot] for more information. */ @WorkflowUiExperimentalApi -public fun ScreenViewFactoryFinder.withCompositionRoot( +public fun ScreenComposableFactoryFinder.withCompositionRoot( root: CompositionRoot -): ScreenViewFactoryFinder = - mapFactories { factory -> +): ScreenComposableFactoryFinder { + return mapFactories { factory -> @Suppress("UNCHECKED_CAST") - (factory as? ComposeScreenViewFactory)?.let { composeFactory -> - composeScreenViewFactory(composeFactory.type) { rendering, environment -> + (factory as? ScreenComposableFactory)?.let { composeFactory -> + ScreenComposableFactory(composeFactory.type) { rendering, environment -> WrappedWithRootIfNecessary(root) { composeFactory.Content(rendering, environment) } } } ?: factory } +} /** * Adds [content] to the composition, ensuring that [CompositionRoot] has been applied. Will only @@ -85,20 +85,21 @@ internal fun WrappedWithRootIfNecessary( } @WorkflowUiExperimentalApi -private fun ScreenViewFactoryFinder.mapFactories( - transform: (ScreenViewFactory<*>) -> ScreenViewFactory<*> -): ScreenViewFactoryFinder = object : ScreenViewFactoryFinder { - override fun getViewFactoryForRendering( +private fun ScreenComposableFactoryFinder.mapFactories( + transform: (ScreenComposableFactory<*>) -> ScreenComposableFactory<*> +): ScreenComposableFactoryFinder = object : ScreenComposableFactoryFinder { + override fun getComposableFactoryForRendering( environment: ViewEnvironment, rendering: ScreenT - ): ScreenViewFactory { - val factoryFor = this@mapFactories.getViewFactoryForRendering(environment, rendering) + ): ScreenComposableFactory? { + val factoryFor = this@mapFactories.getComposableFactoryForRendering(environment, rendering) + ?: return null val transformedFactory = transform(factoryFor) check(transformedFactory.type == rendering::class) { - "Expected transform to return a ScreenViewFactory that is compatible " + + "Expected transform to return a ScreenComposableFactory that is compatible " + "with ${rendering::class}, but got one with type ${transformedFactory.type}" } @Suppress("UNCHECKED_CAST") - return transformedFactory as ScreenViewFactory + return transformedFactory as ScreenComposableFactory } } diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt new file mode 100644 index 0000000000..77a9d19c4f --- /dev/null +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt @@ -0,0 +1,233 @@ +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.view.ViewGroup +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.activity.setViewTreeOnBackPressedDispatcherOwner +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.setViewTreeLifecycleOwner +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewHolder +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.ViewRegistry.Key +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.androidx.OnBackPressedDispatcherOwnerKey +import com.squareup.workflow1.ui.show +import com.squareup.workflow1.ui.startShowing +import kotlin.reflect.KClass + +@WorkflowUiExperimentalApi +public inline fun ScreenComposableFactory( + noinline content: @Composable ( + rendering: ScreenT, + environment: ViewEnvironment + ) -> Unit +): ScreenComposableFactory = ScreenComposableFactory(ScreenT::class, content) + +@PublishedApi +@WorkflowUiExperimentalApi +internal fun ScreenComposableFactory( + type: KClass, + content: @Composable ( + rendering: ScreenT, + environment: ViewEnvironment + ) -> Unit +): ScreenComposableFactory = object : ScreenComposableFactory { + override val type: KClass = type + + @Composable override fun Content( + rendering: ScreenT, + environment: ViewEnvironment + ) { + content(rendering, environment) + } +} + +/** + * A [ViewRegistry.Entry] that uses a [Composable] function to display [ScreenT]. + * This is the Compose-based analogue of + * [ScreenViewFactory][com.squareup.workflow1.ui.ScreenViewFactory]. But unlike + * [ScreenViewFactory], it is unusual for app developers to interact with this + * class directly. Most of the time you can subclass [ComposeScreen] and rely on + * the standard hidden [ScreenComposableFactory] that backs it. + * + * - See [ComposeScreen] for a more complete description of using Compose to + * build a Workflow-based UI. + * + * - See [WorkflowRendering] to display a nested [Screen] from [ComposeScreen.Content] + * or from [ScreenComposableFactory.Content] + * + * Use [ScreenComposableFactory] directly if you need to prevent your + * [Screen] rendering classes from depending on Compose at compile time. + * + * Example: + * + * val fooComposableFactory = ScreenComposableFactory { screen, _ -> + * Text(screen.message) + * } + * + * val viewRegistry = ViewRegistry(fooComposableFactory, …) + * val viewEnvironment = ViewEnvironment.EMPTY + viewRegistry + * + * renderWorkflowIn( + * workflow = MyWorkflow.mapRendering { it.withEnvironment(viewEnvironment) } + * ) + * + */ +@WorkflowUiExperimentalApi +public interface ScreenComposableFactory : ViewRegistry.Entry { + public val type: KClass + + override val key: Key> + get() = Key(type, ScreenComposableFactory::class) + + /** + * The composable content of this [ScreenComposableFactory]. This method will be called + * any time [rendering] or [environment] change. It is the Compose-based analogue of + * [ScreenViewRunner.showRendering][com.squareup.workflow1.ui.ScreenViewRunner.showRendering]. + */ + @Composable public fun Content( + rendering: ScreenT, + environment: ViewEnvironment + ) +} + +/** + * It is rare to call this method directly. Instead the most common path is to pass [Screen] + * instances to [WorkflowRendering], which will apply the [ScreenComposableFactory] + * and [ScreenComposableFactoryFinder] machinery for you. + */ +@WorkflowUiExperimentalApi +public fun ScreenT.toComposableFactory( + environment: ViewEnvironment +): ScreenComposableFactory { + return environment[ScreenComposableFactoryFinder] + .requireComposableFactoryForRendering(environment, this) +} + +/** + * Convert a [ScreenComposableFactory] into a [ScreenViewFactory] + * by using a [ComposeView] to host [ScreenComposableFactory.Content]. + * + * It is unusual to use this function directly, it is mainly an implementation detail + * of [ViewEnvironment.withComposeInteropSupport]. + */ +@WorkflowUiExperimentalApi +public fun ScreenComposableFactory.asViewFactory(): + ScreenViewFactory { + + return object : ScreenViewFactory { + override val type = this@asViewFactory.type + + override fun buildView( + initialRendering: ScreenT, + initialEnvironment: ViewEnvironment, + context: Context, + container: ViewGroup? + ): ScreenViewHolder { + val view = ComposeView(context) + return ScreenViewHolder(initialEnvironment, view) { newRendering, environment -> + // Update the state whenever a new rendering is emitted. + // This lambda will be executed synchronously before ScreenViewHolder.show returns. + view.setContent { Content(newRendering, environment) } + } + } + } +} + +/** + * Convert a [ScreenViewFactory] to a [ScreenComposableFactory], + * using [AndroidView] to host the `View` it builds. + * + * It is unusual to use this function directly, it is mainly an implementation detail + * of [ViewEnvironment.withComposeInteropSupport]. + */ +@WorkflowUiExperimentalApi +public fun ScreenViewFactory.asComposableFactory(): + ScreenComposableFactory { + return object : ScreenComposableFactory { + private val viewFactory = this@asComposableFactory + + override val type: KClass get() = viewFactory.type + + /** + * This is effectively the logic of `WorkflowViewStub`, but translated into Compose idioms. + * This approach has a few advantages: + * + * - Avoids extra custom views required to host `WorkflowViewStub` inside a Composition. Its trick + * of replacing itself in its parent doesn't play nicely with Compose. + * - Allows us to pass the correct parent view for inflation (the root of the composition). + * - Avoids `WorkflowViewStub` having to do its own lookup to find the correct + * [ScreenViewFactory], since we already have the correct one. + * - Propagate the current `LifecycleOwner` from [LocalLifecycleOwner] by setting it as the + * [ViewTreeLifecycleOwner] on the view. + * - Propagate the current [OnBackPressedDispatcherOwner] from either + * [LocalOnBackPressedDispatcherOwner] or the [viewEnvironment], + * both on the [AndroidView] via [setViewTreeOnBackPressedDispatcherOwner], + * and in the [ViewEnvironment] for use by any nested [WorkflowViewStub] + * + * Like `WorkflowViewStub`, this function uses the [viewFactory] to create and memoize a + * `View` to display the [rendering], keeps it updated with the latest [rendering] and + * [environment], and adds it to the composition. + */ + @Composable override fun Content( + rendering: ScreenT, + environment: ViewEnvironment + ) { + val lifecycleOwner = LocalLifecycleOwner.current + + // Make sure any nested WorkflowViewStub will be able to propagate the + // OnBackPressedDispatcherOwner, if we found one. No need to fail fast here. + // It's only an issue if someone tries to use it, and the error message + // at those call sites should be clear enough. + val onBackOrNull = LocalOnBackPressedDispatcherOwner.current + ?: environment.map[OnBackPressedDispatcherOwnerKey] as? OnBackPressedDispatcherOwner + + val envWithOnBack = onBackOrNull + ?.let { environment + (OnBackPressedDispatcherOwnerKey to it) } + ?: environment + + AndroidView( + factory = { context -> + + // We pass in a null container because the container isn't a View, it's a composable. The + // compose machinery will generate an intermediate view that it ends up adding this to but + // we don't have access to that. + viewFactory + .startShowing(rendering, envWithOnBack, context, container = null) + .let { viewHolder -> + // Put the viewHolder in a tag so that we can find it in the update lambda, below. + viewHolder.view.setTag(R.id.workflow_screen_view_holder, viewHolder) + + // Unfortunately AndroidView doesn't propagate these itself. + viewHolder.view.setViewTreeLifecycleOwner(lifecycleOwner) + onBackOrNull?.let { + viewHolder.view.setViewTreeOnBackPressedDispatcherOwner(it) + } + + // We don't propagate the (non-compose) SavedStateRegistryOwner, or the (compose) + // SaveableStateRegistry, because currently all our navigation is implemented as + // Android views, which ensures there is always an Android view between any state + // registry and any Android view shown as a child of it, even if there's a compose + // view in between. + viewHolder.view + } + }, + // This function will be invoked every time this composable is recomposed, which means that + // any time a new rendering or view environment are passed in we'll send them to the view. + update = { view -> + @Suppress("UNCHECKED_CAST") + val viewHolder = + view.getTag(R.id.workflow_screen_view_holder) as ScreenViewHolder + viewHolder.show(rendering, envWithOnBack) + } + ) + } + } +} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt new file mode 100644 index 0000000000..1462c3cccf --- /dev/null +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt @@ -0,0 +1,69 @@ +package com.squareup.workflow1.ui.compose + +import com.squareup.workflow1.ui.EnvironmentScreen +import com.squareup.workflow1.ui.NamedScreen +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.ViewRegistry.Key +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.getFactoryFor + +@WorkflowUiExperimentalApi +public interface ScreenComposableFactoryFinder { + public fun getComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenComposableFactory? { + val factoryOrNull: ScreenComposableFactory? = + environment[ViewRegistry].getFactoryFor(rendering) + + @Suppress("UNCHECKED_CAST") + return factoryOrNull + ?: (rendering as? ComposeScreen)?.let { + ScreenComposableFactory { rendering, environment -> + // Do we need to key on either, both params? + rendering.Content(environment) + } as ScreenComposableFactory + } + // Support for Compose BackStackScreen, BodyAndOverlaysScreen treatments would go here + // See similar blocks in ScreenViewFactoryFinder + ?: (rendering as? NamedScreen<*>)?.let { + ScreenComposableFactory> { rendering, environment -> + val innerFactory = rendering.content.toComposableFactory(environment) + innerFactory.Content(rendering.content, environment) + // WorkflowRendering(rendering.content, environment) + } as ScreenComposableFactory + } + ?: (rendering as? EnvironmentScreen<*>)?.let { + ScreenComposableFactory> { rendering, environment -> + val comboEnv = environment + rendering.environment + val innerFactory = rendering.content.toComposableFactory(comboEnv) + innerFactory.Content(rendering.content, comboEnv) + // WorkflowRendering(rendering.content, comboEnv) + } as ScreenComposableFactory + } + } + + public companion object : ViewEnvironmentKey() { + override val default: ScreenComposableFactoryFinder + get() = object : ScreenComposableFactoryFinder {} + } +} + +@WorkflowUiExperimentalApi +public fun ScreenComposableFactoryFinder.requireComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT +): ScreenComposableFactory { + return getComposableFactoryForRendering(environment, rendering) + ?: throw IllegalArgumentException( + "A ScreenComposableFactory should have been registered to display $rendering, " + + "or that class should implement ComposeScreen. Instead found " + + "${ + environment[ViewRegistry] + .getEntryFor(Key(rendering::class, ScreenComposableFactory::class)) + }." + ) +} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt new file mode 100644 index 0000000000..99bdeae589 --- /dev/null +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt @@ -0,0 +1,57 @@ +package com.squareup.workflow1.ui.compose + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewFactoryFinder +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Replaces the [ScreenComposableFactoryFinder] and [ScreenViewFactoryFinder] + * found in the receiving [ViewEnvironment] with wrappers that are able to + * delegate from one platform to the other. Required to allow + * [WorkflowViewStub][com.squareup.workflow1.ui.WorkflowViewStub] + * to handle renderings bound to `@Composable` functions, and to allow + * [WorkflowRendering] to handle renderings bound to [ScreenViewFactory]. + * + * Note that the standard navigation related [Screen] types + * (e.g. [BackStackScreen][com.squareup.workflow1.ui.navigation.BackStackScreen]) + * are mainly bound to [View][android.view.View]-based implementations. + * Until that changes, effectively every Compose-based app must call this method. + * + * App-specific customizations of [ScreenComposableFactoryFinder] and [ScreenViewFactoryFinder] + * must be placed in the [ViewEnvironment] before calling this method. + */ +@WorkflowUiExperimentalApi +public fun ViewEnvironment.withComposeInteropSupport(): ViewEnvironment { + val rawViewFactoryFinder = get(ScreenViewFactoryFinder) + val rawComposableFactoryFinder = get(ScreenComposableFactoryFinder) + + val convertingViewFactoryFinder = object : ScreenViewFactoryFinder { + override fun getViewFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenViewFactory? { + return rawViewFactoryFinder.getViewFactoryForRendering(environment, rendering) + ?: rawComposableFactoryFinder.getComposableFactoryForRendering(environment, rendering) + ?.asViewFactory() + } + } + + val convertingComposableFactoryFinder = object : ScreenComposableFactoryFinder { + override fun getComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenComposableFactory? { + return rawComposableFactoryFinder.getComposableFactoryForRendering(environment, rendering) + ?: rawViewFactoryFinder.getViewFactoryForRendering(environment, rendering) + ?.asComposableFactory() + } + } + + // TODO consider something like LocalHasViewFactoryRootBeenApplied to enforce + // no duplicate calls + + return this + (ScreenViewFactoryFinder to convertingViewFactoryFinder) + + (ScreenComposableFactoryFinder to convertingComposableFactoryFinder) +} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt index 09e85ae9ab..4dd155b5d6 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt @@ -1,9 +1,5 @@ package com.squareup.workflow1.ui.compose -import android.view.View -import androidx.activity.OnBackPressedDispatcherOwner -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.activity.setViewTreeOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -12,17 +8,14 @@ import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.State.DESTROYED import androidx.lifecycle.Lifecycle.State.INITIALIZED import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.setViewTreeLifecycleOwner import com.squareup.workflow1.ui.Compatible import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewFactoryFinder import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.ViewEnvironment @@ -30,10 +23,6 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.WorkflowViewStub import com.squareup.workflow1.ui.androidx.OnBackPressedDispatcherOwnerKey import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner -import com.squareup.workflow1.ui.show -import com.squareup.workflow1.ui.startShowing -import com.squareup.workflow1.ui.toViewFactory -import kotlin.reflect.KClass /** * Renders [rendering] into the composition using this [ViewEnvironment]'s @@ -83,13 +72,13 @@ public fun WorkflowRendering( // factory created an Android view, this will also remove the old one from the view hierarchy // before replacing it with the new one. key(renderingCompatibilityKey) { - val viewFactory = remember { + val composableFactory = remember { // The view registry may return a new factory instance for a rendering every time we ask it, for // example if an AndroidScreen doesn't share its factory between rendering instances. We // intentionally don't ask it for a new instance every time to match the behavior of // WorkflowViewStub and other containers, which only ask for a new factory when the rendering is // incompatible. - rendering.toViewFactory(viewEnvironment).asComposeViewFactory() + rendering.toComposableFactory(viewEnvironment) } // Just like WorkflowViewStub, we need to manage a Lifecycle for the child view. We just provide @@ -103,7 +92,7 @@ public fun WorkflowRendering( // into this function is to directly control the layout of the child view – which means // minimum constraints are likely to be significant. Box(modifier, propagateMinConstraints = true) { - viewFactory.Content(rendering, viewEnvironment) + composableFactory.Content(rendering, viewEnvironment) } } } @@ -146,90 +135,3 @@ public fun WorkflowRendering( return lifecycleOwner } - -/** - * Returns a [ComposeScreenViewFactory] that makes it convenient to display this [ScreenViewFactory] - * as a composable. If this is a [ComposeScreenViewFactory] already it just returns `this`, - * otherwise it wraps the factory in one that manages a classic Android view. - */ -@OptIn(WorkflowUiExperimentalApi::class) -private fun ScreenViewFactory.asComposeViewFactory() = - (this as? ComposeScreenViewFactory) ?: object : ComposeScreenViewFactory() { - - private val originalFactory = this@asComposeViewFactory - override val type: KClass get() = originalFactory.type - - /** - * This is effectively the logic of [WorkflowViewStub], but translated into Compose idioms. - * This approach has a few advantages: - * - * - Avoids extra custom views required to host `WorkflowViewStub` inside a Composition. Its trick - * of replacing itself in its parent doesn't play nicely with Compose. - * - Allows us to pass the correct parent view for inflation (the root of the composition). - * - Avoids `WorkflowViewStub` having to do its own lookup to find the correct - * [ScreenViewFactory], since we already have the correct one. - * - Propagate the current [LifecycleOwner] from [LocalLifecycleOwner] by setting it as the - * [ViewTreeLifecycleOwner] on the view. - * - Propagate the current [OnBackPressedDispatcherOwner] from either - * [LocalOnBackPressedDispatcherOwner] or the [viewEnvironment], - * both on the [AndroidView] via [setViewTreeOnBackPressedDispatcherOwner], - * and in the [ViewEnvironment] for use by any nested [WorkflowViewStub] - * - * Like `WorkflowViewStub`, this function uses the [originalFactory] to create and memoize a - * [View] to display the [rendering], keeps it updated with the latest [rendering] and - * [viewEnvironment], and adds it to the composition. - */ - @Composable override fun Content( - rendering: ScreenT, - viewEnvironment: ViewEnvironment - ) { - val lifecycleOwner = LocalLifecycleOwner.current - - // Make sure any nested WorkflowViewStub will be able to propagate the - // OnBackPressedDispatcherOwner, if we found one. No need to fail fast here. - // It's only an issue if someone tries to use it, and the error message - // at those call sites should be clear enough. - val onBackOrNull = LocalOnBackPressedDispatcherOwner.current - ?: viewEnvironment.map[OnBackPressedDispatcherOwnerKey] as? OnBackPressedDispatcherOwner - - val envWithOnBack = onBackOrNull - ?.let { viewEnvironment + (OnBackPressedDispatcherOwnerKey to it) } - ?: viewEnvironment - - AndroidView( - factory = { context -> - - // We pass in a null container because the container isn't a View, it's a composable. The - // compose machinery will generate an intermediate view that it ends up adding this to but - // we don't have access to that. - originalFactory - .startShowing(rendering, envWithOnBack, context, container = null) - .let { viewHolder -> - // Put the viewHolder in a tag so that we can find it in the update lambda, below. - viewHolder.view.setTag(R.id.workflow_screen_view_holder, viewHolder) - - // Unfortunately AndroidView doesn't propagate these itself. - viewHolder.view.setViewTreeLifecycleOwner(lifecycleOwner) - onBackOrNull?.let { - viewHolder.view.setViewTreeOnBackPressedDispatcherOwner(it) - } - - // We don't propagate the (non-compose) SavedStateRegistryOwner, or the (compose) - // SaveableStateRegistry, because currently all our navigation is implemented as - // Android views, which ensures there is always an Android view between any state - // registry and any Android view shown as a child of it, even if there's a compose - // view in between. - viewHolder.view - } - }, - // This function will be invoked every time this composable is recomposed, which means that - // any time a new rendering or view environment are passed in we'll send them to the view. - update = { view -> - @Suppress("UNCHECKED_CAST") - val viewHolder = - view.getTag(R.id.workflow_screen_view_holder) as ScreenViewHolder - viewHolder.show(rendering, envWithOnBack) - } - ) - } - } diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index b9816a1245..d23069648b 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -66,6 +66,10 @@ public final class com/squareup/workflow1/ui/ScreenViewFactoryFinder$DefaultImpl public static fun getViewFactoryForRendering (Lcom/squareup/workflow1/ui/ScreenViewFactoryFinder;Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/ScreenViewFactory; } +public final class com/squareup/workflow1/ui/ScreenViewFactoryFinderKt { + public static final fun requireViewFactoryForRendering (Lcom/squareup/workflow1/ui/ScreenViewFactoryFinder;Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/ScreenViewFactory; +} + public final class com/squareup/workflow1/ui/ScreenViewFactoryKt { public static final fun startShowing (Lcom/squareup/workflow1/ui/ScreenViewFactory;Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;Lcom/squareup/workflow1/ui/ViewStarter;)Lcom/squareup/workflow1/ui/ScreenViewHolder; public static final fun startShowing (Lcom/squareup/workflow1/ui/ScreenViewHolder;Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/ViewStarter;)V diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactory.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactory.kt index 728267b271..20d9488505 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactory.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactory.kt @@ -277,14 +277,16 @@ public interface ScreenViewFactory : ViewRegistry.Entry ScreenT.toViewFactory( environment: ViewEnvironment ): ScreenViewFactory { - return environment[ScreenViewFactoryFinder].getViewFactoryForRendering(environment, this) + return environment[ScreenViewFactoryFinder].requireViewFactoryForRendering(environment, this) } /** diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactoryFinder.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactoryFinder.kt index 6fa4b54acf..a494e376e6 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactoryFinder.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactoryFinder.kt @@ -53,12 +53,12 @@ public interface ScreenViewFactoryFinder { public fun getViewFactoryForRendering( environment: ViewEnvironment, rendering: ScreenT - ): ScreenViewFactory { - val entry = environment[ViewRegistry] - .getEntryFor(Key(rendering::class, ScreenViewFactory::class)) + ): ScreenViewFactory? { + val factoryOrNull: ScreenViewFactory? = + environment[ViewRegistry].getFactoryFor(rendering) @Suppress("UNCHECKED_CAST") - return (entry as? ScreenViewFactory) + return factoryOrNull ?: (rendering as? AndroidScreen<*>)?.viewFactory as? ScreenViewFactory ?: (rendering as? BackStackScreen<*>)?.let { BackStackScreenViewFactory as ScreenViewFactory @@ -76,10 +76,6 @@ public interface ScreenViewFactoryFinder { showContent(envScreen.content, environment + envScreen.environment) } as ScreenViewFactory } - ?: throw IllegalArgumentException( - "A ScreenViewFactory should have been registered to display $rendering, " + - "or that class should implement AndroidScreen. Instead found $entry." - ) } public companion object : ViewEnvironmentKey() { @@ -87,3 +83,19 @@ public interface ScreenViewFactoryFinder { get() = object : ScreenViewFactoryFinder {} } } + +@WorkflowUiExperimentalApi +public fun ScreenViewFactoryFinder.requireViewFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT +): ScreenViewFactory { + return getViewFactoryForRendering(environment, rendering) + ?: throw IllegalArgumentException( + "A ScreenViewFactory should have been registered to display $rendering, " + + "or that class should implement AndroidScreen. Instead found " + + "${ + environment[ViewRegistry] + .getEntryFor(Key(rendering::class, ScreenViewFactory::class)) + }." + ) +} diff --git a/workflow-ui/core-common/api/core-common.api b/workflow-ui/core-common/api/core-common.api index 561ae612a0..28781a9287 100644 --- a/workflow-ui/core-common/api/core-common.api +++ b/workflow-ui/core-common/api/core-common.api @@ -32,6 +32,7 @@ public final class com/squareup/workflow1/ui/EnvironmentScreen : com/squareup/wo public final class com/squareup/workflow1/ui/EnvironmentScreenKt { public static final fun withEnvironment (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/EnvironmentScreen; + public static final fun withEnvironment (Lcom/squareup/workflow1/ui/Screen;Lkotlin/Pair;)Lcom/squareup/workflow1/ui/EnvironmentScreen; public static synthetic fun withEnvironment$default (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/EnvironmentScreen; public static final fun withRegistry (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewRegistry;)Lcom/squareup/workflow1/ui/EnvironmentScreen; } diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/EnvironmentScreen.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/EnvironmentScreen.kt index bf3137101f..cfbbe8ea2d 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/EnvironmentScreen.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/EnvironmentScreen.kt @@ -53,3 +53,12 @@ public fun Screen.withEnvironment( else -> EnvironmentScreen(this, environment) } } + +/** + * Returns an [EnvironmentScreen] derived from the receiver, + * whose [EnvironmentScreen.environment] includes the given entry. + */ +@WorkflowUiExperimentalApi +public fun Screen.withEnvironment( + entry: Pair, T> +): EnvironmentScreen<*> = withEnvironment(ViewEnvironment.EMPTY + entry) diff --git a/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/EnvironmentScreenTest.kt b/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/EnvironmentScreenTest.kt index 370711a40b..d15ddfb301 100644 --- a/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/EnvironmentScreenTest.kt +++ b/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/EnvironmentScreenTest.kt @@ -25,19 +25,20 @@ internal class EnvironmentScreenTest { } private object FooScreen : Screen - private val fooKey = Key(FooScreen::class, TestFactory::class) private object BarScreen : Screen - private val barKey = Key(BarScreen::class, TestFactory::class) @Test fun `Screen withRegistry works`() { val fooFactory = TestFactory(FooScreen::class) val viewRegistry = ViewRegistry(fooFactory) val envScreen = FooScreen.withRegistry(viewRegistry) - assertThat(envScreen.environment[ViewRegistry][fooKey]) - .isSameInstanceAs(fooFactory) + assertThat( + envScreen.environment[ViewRegistry].getFactoryFor>(FooScreen) + ).isSameInstanceAs(fooFactory) - assertThat(envScreen.environment[ViewRegistry][barKey]).isNull() + assertThat( + envScreen.environment[ViewRegistry].getFactoryFor>(BarScreen) + ).isNull() } @Test fun `Screen withEnvironment works`() { @@ -47,10 +48,12 @@ internal class EnvironmentScreenTest { EMPTY + viewRegistry + TestValue("foo") ) - assertThat(envScreen.environment[ViewRegistry][fooKey]) - .isSameInstanceAs(fooFactory) - assertThat(envScreen.environment[ViewRegistry][barKey]) - .isNull() + assertThat( + envScreen.environment[ViewRegistry].getFactoryFor>(FooScreen) + ).isSameInstanceAs(fooFactory) + assertThat( + envScreen.environment[ViewRegistry].getFactoryFor>(BarScreen) + ).isNull() assertThat(envScreen.environment[TestValue]) .isEqualTo(TestValue("foo")) } @@ -63,11 +66,13 @@ internal class EnvironmentScreenTest { val left = FooScreen.withRegistry(ViewRegistry(fooFactory1, barFactory)) val union = left.withRegistry(ViewRegistry(fooFactory2)) - assertThat(union.environment[ViewRegistry][fooKey]) - .isSameInstanceAs(fooFactory2) + assertThat( + union.environment[ViewRegistry].getFactoryFor>(FooScreen) + ).isSameInstanceAs(fooFactory2) - assertThat(union.environment[ViewRegistry][barKey]) - .isSameInstanceAs(barFactory) + assertThat( + union.environment[ViewRegistry].getFactoryFor>(BarScreen) + ).isSameInstanceAs(barFactory) } @Test fun `EnvironmentScreen withEnvironment merges`() { @@ -83,10 +88,12 @@ internal class EnvironmentScreenTest { EMPTY + ViewRegistry(fooFactory2) + TestValue("right") ) - assertThat(union.environment[ViewRegistry][fooKey]) - .isSameInstanceAs(fooFactory2) - assertThat(union.environment[ViewRegistry][barKey]) - .isSameInstanceAs(barFactory) + assertThat( + union.environment[ViewRegistry].getFactoryFor>(FooScreen) + ).isSameInstanceAs(fooFactory2) + assertThat( + union.environment[ViewRegistry].getFactoryFor>(BarScreen) + ).isSameInstanceAs(barFactory) assertThat(union.environment[TestValue]) .isEqualTo(TestValue("right")) }