diff --git a/artifacts.json b/artifacts.json index 3b1f1335e..3890a37a8 100644 --- a/artifacts.json +++ b/artifacts.json @@ -188,24 +188,6 @@ "javaVersion": 8, "publicationName": "maven" }, - { - "gradlePath": ":workflow-ui:container-android", - "group": "com.squareup.workflow1", - "artifactId": "workflow-ui-container-android", - "description": "Workflow UI Container Android", - "packaging": "aar", - "javaVersion": 8, - "publicationName": "maven" - }, - { - "gradlePath": ":workflow-ui:container-common", - "group": "com.squareup.workflow1", - "artifactId": "workflow-ui-container-common-jvm", - "description": "Workflow UI Container", - "packaging": "jar", - "javaVersion": 8, - "publicationName": "maven" - }, { "gradlePath": ":workflow-ui:core-android", "group": "com.squareup.workflow1", 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 affb765fd..9a4706258 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 0309a095b..000000000 --- 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 000000000..88e8f8b87 --- /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 16bc8142b..9d78d09de 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 be9dac792..aa3f65e67 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 e175ac048..f20954ba3 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 8526eb7b6..478cd73e0 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 b6f50e7dc..8386aaa99 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 8e53b7026..3e77d96fb 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 66bb94a2a..21426f6f4 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 e9296e3dd..6510d6f93 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 51a296272..ca8a06b5a 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 648596c3b..0a8025cce 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 @@ -26,8 +26,8 @@ import com.squareup.sample.compose.nestedrenderings.RecursiveWorkflow.Rendering import com.squareup.workflow1.ui.Screen 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.WorkflowRendering -import com.squareup.workflow1.ui.compose.composeScreenViewFactory 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 172452a08..a252484f9 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 51872aacd..1606fc23b 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 994e7db4c..979dedf2e 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 @@ -19,12 +19,12 @@ import androidx.compose.ui.unit.dp 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.ScreenComposableFactory import com.squareup.workflow1.ui.compose.asMutableState -import com.squareup.workflow1.ui.compose.composeScreenViewFactory 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 d912a3aa6..000000000 --- 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 a3f8dd69a..def69e533 100644 --- a/workflow-ui/compose-tooling/api/compose-tooling.api +++ b/workflow-ui/compose-tooling/api/compose-tooling.api @@ -1,11 +1,6 @@ -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 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 5a9012044..a9e261a20 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 @@ -17,8 +17,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironmentKey import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.ScreenComposableFactory import com.squareup.workflow1.ui.compose.WorkflowRendering -import com.squareup.workflow1.ui.compose.composeScreenViewFactory 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 e2b544b6f..80483e3bf 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 @@ -23,17 +23,18 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp 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 620116077..f324f8d91 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,41 @@ 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 +53,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 000000000..fbc8dbd55 --- /dev/null +++ b/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/Previews.kt @@ -0,0 +1,113 @@ +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 334b76204..000000000 --- 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 4f2c8b6b2..c58169ff4 100644 --- a/workflow-ui/compose/README.md +++ b/workflow-ui/compose/README.md @@ -1,11 +1,11 @@ # 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 design 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 +23,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 +39,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 +57,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 +95,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 +113,108 @@ 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 another 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 +225,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 +243,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 - -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. +#### Previewing Compose-based (and non-Compose!) 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. +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. -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 +294,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 +318,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 eede71a59..c5c72f711 100644 --- a/workflow-ui/compose/api/compose.api +++ b/workflow-ui/compose/api/compose.api @@ -1,40 +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 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/build.gradle.kts b/workflow-ui/compose/build.gradle.kts index ab5ef8990..1bc485931 100644 --- a/workflow-ui/compose/build.gradle.kts +++ b/workflow-ui/compose/build.gradle.kts @@ -13,6 +13,10 @@ android { composeOptions { kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() } + defaultConfig { + // https://issuetracker.google.com/issues/194289155#comment21 + minSdk = 24 + } namespace = "com.squareup.workflow1.ui.compose" testNamespace = "$namespace.test" } 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 27f7ab2cc..23402346f 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 107ed58f5..bd12f84db 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 0d5593074..06a00f645 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 @@ -1,48 +1,80 @@ 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. - * This is the compose analog to [AndroidScreen]. + * appropriate [ScreenComposableFactory] implementation, by simply overriding the [Content] method. + * + * Note that it is generally an error for a [Workflow][com.squareup.workflow1.Workflow] + * to declare [ComposeScreen] as its `RenderingT` type -- prefer [Screen] for that. + * [ComposeScreen], like [AndroidScreen][com.squareup.workflow1.ui.AndroidScreen], + * is strictly a possible implementation detail of [Screen]. It is a convenience to + * minimize the boilerplate required to set up a [ScreenComposableFactory]. + * That interface is the fundamental unit of Compose tooling for Workflow UI. + * But in day to day use, most developer will work with [ComposeScreen] and be only + * vaguely aware of the existence of [ScreenComposableFactory], + * so the bulk of our description of working with Compose is here. + * + * **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 +82,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 995afdd77..000000000 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreenViewFactory.kt +++ /dev/null @@ -1,139 +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.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 { - /** - * 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 07f65424d..1902c7f9f 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 000000000..b8a6e6b20 --- /dev/null +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt @@ -0,0 +1,234 @@ +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 fundamental unit of Compose tooling in Workflow UI, the Compose analogue of + * [ScreenViewFactory][com.squareup.workflow1.ui.ScreenViewFactory]. + * + * [ScreenComposableFactory] is also a bit cumbersome to use directly, + * so [ComposeScreen] is provided as a convenience. Most developers will + * have no reason to work with [ScreenComposableFactory] directly, or even + * be aware of 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 000000000..3466c48bb --- /dev/null +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt @@ -0,0 +1,70 @@ +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 -> + rendering.Content(environment) + } as ScreenComposableFactory + } + + // Support for Compose BackStackScreen, BodyAndOverlaysScreen treatments would go here, + // if it were planned. 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 000000000..04c809dbd --- /dev/null +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt @@ -0,0 +1,54 @@ +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() + } + } + + 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 09e85ae9a..e8a4f9b24 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,28 +8,20 @@ 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 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 +71,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 +91,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 +134,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 f05afe938..d23069648 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -14,6 +14,7 @@ public abstract interface class com/squareup/workflow1/ui/AndroidScreen : com/sq public final class com/squareup/workflow1/ui/LayoutScreenViewFactory : com/squareup/workflow1/ui/ScreenViewFactory { public fun (Lkotlin/reflect/KClass;ILkotlin/jvm/functions/Function1;)V public fun buildView (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;)Lcom/squareup/workflow1/ui/ScreenViewHolder; + public fun getKey ()Lcom/squareup/workflow1/ui/ViewRegistry$Key; public fun getType ()Lkotlin/reflect/KClass; } @@ -39,6 +40,8 @@ public final class com/squareup/workflow1/ui/ParcelableTextController$CREATOR : public abstract interface class com/squareup/workflow1/ui/ScreenViewFactory : com/squareup/workflow1/ui/ViewRegistry$Entry { public static final field Companion Lcom/squareup/workflow1/ui/ScreenViewFactory$Companion; public abstract fun buildView (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;)Lcom/squareup/workflow1/ui/ScreenViewHolder; + public abstract fun getKey ()Lcom/squareup/workflow1/ui/ViewRegistry$Key; + public abstract fun getType ()Lkotlin/reflect/KClass; } public final class com/squareup/workflow1/ui/ScreenViewFactory$Companion { @@ -46,6 +49,7 @@ public final class com/squareup/workflow1/ui/ScreenViewFactory$Companion { public final class com/squareup/workflow1/ui/ScreenViewFactory$DefaultImpls { public static synthetic fun buildView$default (Lcom/squareup/workflow1/ui/ScreenViewFactory;Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/ScreenViewHolder; + public static fun getKey (Lcom/squareup/workflow1/ui/ScreenViewFactory;)Lcom/squareup/workflow1/ui/ViewRegistry$Key; } public abstract interface class com/squareup/workflow1/ui/ScreenViewFactoryFinder { @@ -62,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 @@ -101,6 +109,7 @@ public final class com/squareup/workflow1/ui/TextControllerControlEditTextKt { public final class com/squareup/workflow1/ui/ViewBindingScreenViewFactory : com/squareup/workflow1/ui/ScreenViewFactory { public fun (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;)V public fun buildView (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;)Lcom/squareup/workflow1/ui/ScreenViewHolder; + public fun getKey ()Lcom/squareup/workflow1/ui/ViewRegistry$Key; public fun getType ()Lkotlin/reflect/KClass; } @@ -283,11 +292,17 @@ public final class com/squareup/workflow1/ui/navigation/LayeredDialogSessions$Sa public abstract interface class com/squareup/workflow1/ui/navigation/OverlayDialogFactory : com/squareup/workflow1/ui/ViewRegistry$Entry { public static final field Companion Lcom/squareup/workflow1/ui/navigation/OverlayDialogFactory$Companion; public abstract fun buildDialog (Lcom/squareup/workflow1/ui/navigation/Overlay;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;)Lcom/squareup/workflow1/ui/navigation/OverlayDialogHolder; + public abstract fun getKey ()Lcom/squareup/workflow1/ui/ViewRegistry$Key; + public abstract fun getType ()Lkotlin/reflect/KClass; } public final class com/squareup/workflow1/ui/navigation/OverlayDialogFactory$Companion { } +public final class com/squareup/workflow1/ui/navigation/OverlayDialogFactory$DefaultImpls { + public static fun getKey (Lcom/squareup/workflow1/ui/navigation/OverlayDialogFactory;)Lcom/squareup/workflow1/ui/ViewRegistry$Key; +} + public abstract interface class com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder { public static final field Companion Lcom/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder$Companion; public abstract fun getDialogFactoryForRendering (Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/navigation/Overlay;)Lcom/squareup/workflow1/ui/navigation/OverlayDialogFactory; diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt index 5ec682335..143fa2553 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt @@ -1,8 +1,13 @@ package com.squareup.workflow1.ui /** - * Interface implemented by a rendering class to allow it to drive an Android UI - * via an appropriate [ScreenViewFactory] implementation. + * Interface implemented by a [Screen] rendering class to minimize the boilerplate + * required for it to drive an Android UI via an appropriate [ScreenViewFactory] + * implementation. + * + * Note that it is generally an error for a [Workflow][com.squareup.workflow1.Workflow] + * to declare [AndroidScreen] as its `RenderingT` type -- prefer [Screen] for that. + * [AndroidScreen] is strictly a possible implementation detail of [Screen]. * * You will rarely, if ever, write a [ScreenViewFactory] yourself. Use one * of its [companion methods][ScreenViewFactory.Companion] like [ScreenViewFactory.fromViewBinding] 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 e0fab7089..20d948850 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 @@ -9,6 +9,8 @@ import androidx.viewbinding.ViewBinding import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromCode import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromLayout import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromViewBinding +import com.squareup.workflow1.ui.ViewRegistry.Key +import kotlin.reflect.KClass @WorkflowUiExperimentalApi public typealias ViewBindingInflater = (LayoutInflater, ViewGroup?, Boolean) -> BindingT @@ -48,6 +50,10 @@ public typealias ViewBindingInflater = (LayoutInflater, ViewGroup?, Bo */ @WorkflowUiExperimentalApi public interface ScreenViewFactory : ViewRegistry.Entry { + public val type: KClass + + override val key: Key> get() = Key(type, ScreenViewFactory::class) + /** * It is rare to call this method directly. Instead the most common path is to pass [Screen] * instances to [WorkflowViewStub.show], which will apply the [ScreenViewFactory] machinery for @@ -271,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 89c319a8f..a494e376e 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 @@ -1,6 +1,7 @@ package com.squareup.workflow1.ui import com.squareup.workflow1.ui.ScreenViewFactory.Companion.forWrapper +import com.squareup.workflow1.ui.ViewRegistry.Key import com.squareup.workflow1.ui.navigation.BackStackScreen import com.squareup.workflow1.ui.navigation.BackStackScreenViewFactory import com.squareup.workflow1.ui.navigation.BodyAndOverlaysContainer @@ -52,11 +53,12 @@ public interface ScreenViewFactoryFinder { public fun getViewFactoryForRendering( environment: ViewEnvironment, rendering: ScreenT - ): ScreenViewFactory { - val entry = environment[ViewRegistry].getEntryFor(rendering::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 @@ -74,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() { @@ -85,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-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactory.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactory.kt index 768ca9a83..e7f6ff9c7 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactory.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactory.kt @@ -4,7 +4,9 @@ import android.app.Dialog import android.content.Context 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 kotlin.reflect.KClass /** * Factory for [Dialog] instances that can show renderings of type [OverlayT]. @@ -64,6 +66,10 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi */ @WorkflowUiExperimentalApi public interface OverlayDialogFactory : ViewRegistry.Entry { + public val type: KClass + + override val key: Key get() = Key(type, OverlayDialogFactory::class) + /** Builds a [Dialog], but does not show it. */ public fun buildDialog( initialRendering: OverlayT, diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder.kt index d51e0dfd2..b08edf4f1 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder.kt @@ -4,6 +4,7 @@ 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 /** @@ -17,7 +18,8 @@ public interface OverlayDialogFactoryFinder { environment: ViewEnvironment, rendering: OverlayT ): OverlayDialogFactory { - val entry = environment[ViewRegistry].getEntryFor(rendering::class) + val entry = environment[ViewRegistry] + .getEntryFor(Key(rendering::class, OverlayDialogFactory::class)) @Suppress("UNCHECKED_CAST") return entry as? OverlayDialogFactory diff --git a/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/ScreenViewFactoryTest.kt b/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/ScreenViewFactoryTest.kt index 4a888559b..ea1f2a92d 100644 --- a/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/ScreenViewFactoryTest.kt +++ b/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/ScreenViewFactoryTest.kt @@ -5,6 +5,7 @@ import android.view.ViewGroup import com.google.common.truth.Truth.assertThat import com.squareup.workflow1.ui.ScreenViewFactory.Companion.forWrapper import com.squareup.workflow1.ui.ViewRegistry.Entry +import com.squareup.workflow1.ui.ViewRegistry.Key import org.junit.Test import org.mockito.kotlin.mock import kotlin.reflect.KClass @@ -16,9 +17,10 @@ internal class ScreenViewFactoryTest { @Test fun missingBindingMessage_isUseful() { val emptyReg = object : ViewRegistry { - override val keys: Set> = emptySet() - override fun getEntryFor( - renderingType: KClass + + override val keys: Set> = emptySet() + override fun getEntryFor( + key: Key ): Entry? = null } val env = ViewEnvironment.EMPTY + emptyReg diff --git a/workflow-ui/core-common/api/core-common.api b/workflow-ui/core-common/api/core-common.api index cc002c4ff..28781a928 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; } @@ -94,7 +95,7 @@ public abstract class com/squareup/workflow1/ui/ViewEnvironmentKey { public abstract interface class com/squareup/workflow1/ui/ViewRegistry { public static final field Companion Lcom/squareup/workflow1/ui/ViewRegistry$Companion; - public abstract fun getEntryFor (Lkotlin/reflect/KClass;)Lcom/squareup/workflow1/ui/ViewRegistry$Entry; + public abstract fun getEntryFor (Lcom/squareup/workflow1/ui/ViewRegistry$Key;)Lcom/squareup/workflow1/ui/ViewRegistry$Entry; public abstract fun getKeys ()Ljava/util/Set; } @@ -106,7 +107,16 @@ public final class com/squareup/workflow1/ui/ViewRegistry$Companion : com/square } public abstract interface class com/squareup/workflow1/ui/ViewRegistry$Entry { - public abstract fun getType ()Lkotlin/reflect/KClass; + public abstract fun getKey ()Lcom/squareup/workflow1/ui/ViewRegistry$Key; +} + +public final class com/squareup/workflow1/ui/ViewRegistry$Key { + public fun (Lkotlin/reflect/KClass;Lkotlin/reflect/KClass;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getFactoryType ()Lkotlin/reflect/KClass; + public final fun getRenderingType ()Lkotlin/reflect/KClass; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public final class com/squareup/workflow1/ui/ViewRegistryKt { diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/CompositeViewRegistry.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/CompositeViewRegistry.kt index bce7ddd8e..44501fe2f 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/CompositeViewRegistry.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/CompositeViewRegistry.kt @@ -1,7 +1,7 @@ package com.squareup.workflow1.ui import com.squareup.workflow1.ui.ViewRegistry.Entry -import kotlin.reflect.KClass +import com.squareup.workflow1.ui.ViewRegistry.Key /** * A [ViewRegistry] that contains only other registries and delegates to their [getEntryFor] @@ -20,26 +20,26 @@ import kotlin.reflect.KClass */ @WorkflowUiExperimentalApi internal class CompositeViewRegistry private constructor( - private val registriesByKey: Map, ViewRegistry> + private val registriesByKey: Map, ViewRegistry> ) : ViewRegistry { constructor (vararg registries: ViewRegistry) : this(mergeRegistries(*registries)) - override val keys: Set> get() = registriesByKey.keys + override val keys: Set> get() = registriesByKey.keys - override fun getEntryFor( - renderingType: KClass - ): Entry? = registriesByKey[renderingType]?.getEntryFor(renderingType) + override fun getEntryFor( + key: Key + ): Entry? = registriesByKey[key]?.getEntryFor(key) override fun toString(): String { return "CompositeViewRegistry(${registriesByKey.values.toSet().map { it.toString() }})" } companion object { - private fun mergeRegistries(vararg registries: ViewRegistry): Map, ViewRegistry> { - val registriesByKey = mutableMapOf, ViewRegistry>() + private fun mergeRegistries(vararg registries: ViewRegistry): Map, ViewRegistry> { + val registriesByKey = mutableMapOf, ViewRegistry>() - fun putAllUnique(other: Map, ViewRegistry>) { + fun putAllUnique(other: Map, ViewRegistry>) { val duplicateKeys = registriesByKey.keys.intersect(other.keys) require(duplicateKeys.isEmpty()) { "Must not have duplicate entries: $duplicateKeys. Use merge to replace existing entries." 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 bf3137101..cfbbe8ea2 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/main/java/com/squareup/workflow1/ui/TypedViewRegistry.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/TypedViewRegistry.kt index dc903ed16..5d3fb0ea5 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/TypedViewRegistry.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/TypedViewRegistry.kt @@ -1,6 +1,7 @@ package com.squareup.workflow1.ui import com.squareup.workflow1.ui.ViewRegistry.Entry +import com.squareup.workflow1.ui.ViewRegistry.Key import kotlin.reflect.KClass /** @@ -9,29 +10,34 @@ import kotlin.reflect.KClass */ @WorkflowUiExperimentalApi internal class TypedViewRegistry private constructor( - private val bindings: Map, Entry<*>> + private val bindings: Map, Entry<*>> ) : ViewRegistry { constructor(vararg bindings: Entry<*>) : this( - bindings.associateBy { it.type } + bindings.associateBy { + require(it.key.factoryType.isInstance(it)) { + "Factory $it must be of the type declared in its key, ${it.key.factoryType.qualifiedName}" + } + it.key + } .apply { check(keys.size == bindings.size) { - "${bindings.map { it.type }} must not have duplicate entries." + "${bindings.map { it.key }} must not have duplicate entries." } - } as Map, Entry<*>> + } as Map, Entry<*>> ) - override val keys: Set> get() = bindings.keys + override val keys: Set> get() = bindings.keys - override fun getEntryFor( - renderingType: KClass + override fun getEntryFor( + key: Key ): Entry? { @Suppress("UNCHECKED_CAST") - return bindings[renderingType] as? Entry + return bindings[key] as? Entry } override fun toString(): String { - val map = bindings.map { "${it.key.simpleName}=${it.value::class.qualifiedName}" } + val map = bindings.map { "${it.key}=${it.value::class.qualifiedName}" } return "TypedViewRegistry(bindings=$map)" } } diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/ViewRegistry.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/ViewRegistry.kt index d4566d084..c7354e368 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/ViewRegistry.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/ViewRegistry.kt @@ -1,7 +1,9 @@ package com.squareup.workflow1.ui import com.squareup.workflow1.ui.ViewRegistry.Entry +import com.squareup.workflow1.ui.ViewRegistry.Key import kotlin.reflect.KClass +import kotlin.reflect.safeCast /** * The [ViewEnvironment] service that can be used to display the stream of renderings @@ -61,24 +63,60 @@ import kotlin.reflect.KClass */ @WorkflowUiExperimentalApi public interface ViewRegistry { + /** + * Identifies a UI factory [Entry] in a [ViewRegistry]. + * + * @param renderingType the type of view model for which [factoryType] instances can build UI + * @param factoryType the type of the UI factory that can build UI for [renderingType] + */ + public class Key( + public val renderingType: KClass, + public val factoryType: KClass + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Key<*, *> + + if (renderingType != other.renderingType) return false + return factoryType == other.factoryType + } + + override fun hashCode(): Int { + var result = renderingType.hashCode() + result = 31 * result + factoryType.hashCode() + return result + } + + override fun toString(): String { + return "Key(renderingType=$renderingType, factoryType=$factoryType)" + } + } + + /** + * Implemented by a factory that can build some kind of UI for view models + * of type [RenderingT], and which can be listed in a [ViewRegistry]. The + * [Key.factoryType] field of [key] must be the type of this [Entry]. + */ public interface Entry { - public val type: KClass + public val key: Key } /** * The set of unique keys which this registry can derive from the renderings passed to - * [getEntryFor] and for which it knows how to create views. + * [getEntryFor] and for which it knows how to create UI. * * Used to ensure that duplicate bindings are never registered. */ - public val keys: Set> + public val keys: Set> /** - * Returns the [Entry] that was registered for the given [renderingType], or null + * Returns the [Entry] that was registered for the given [key], or null * if none was found. */ - public fun getEntryFor( - renderingType: KClass + public fun getEntryFor( + key: Key ): Entry? public companion object : ViewEnvironmentKey() { @@ -90,9 +128,25 @@ public interface ViewRegistry { } } -@WorkflowUiExperimentalApi public inline operator fun ViewRegistry.get( - renderingType: KClass -): Entry? = getEntryFor(renderingType) +@WorkflowUiExperimentalApi +public inline fun ViewRegistry.getFactoryFor( + rendering: RenderingT +): FactoryT? { + return FactoryT::class.safeCast(getEntryFor(Key(rendering::class, FactoryT::class))) +} + +@WorkflowUiExperimentalApi +public inline fun < + reified RenderingT : Any, + reified FactoryT : Any + > ViewRegistry.getFactoryFor(): FactoryT? { + return FactoryT::class.safeCast(getEntryFor(Key(RenderingT::class, FactoryT::class))) +} + +@WorkflowUiExperimentalApi +public inline operator fun ViewRegistry.get( + key: Key +): FactoryT? = FactoryT::class.safeCast(getEntryFor(key)) @WorkflowUiExperimentalApi public fun ViewRegistry(vararg bindings: Entry<*>): ViewRegistry = @@ -115,8 +169,10 @@ public operator fun ViewRegistry.plus(entry: Entry<*>): ViewRegistry = this + ViewRegistry(entry) /** - * Transforms the receiver to add all entries from [other], throwing [IllegalArgumentException] - * if the receiver already has any matching [entry]. Use [merge] to replace existing entries. + * Transforms the receiver to add all entries from [other]. + * + * @throws [IllegalArgumentException] if the receiver already has an matching [Entry]. + * Use [merge] to replace existing entries instead. */ @WorkflowUiExperimentalApi public operator fun ViewRegistry.plus(other: ViewRegistry): ViewRegistry { @@ -125,6 +181,11 @@ public operator fun ViewRegistry.plus(other: ViewRegistry): ViewRegistry { return CompositeViewRegistry(this, other) } +/** + * Returns a new [ViewEnvironment] that adds [registry] to the receiver. + * If the receiver already has a [ViewRegistry], [ViewEnvironmentKey.combine] + * is applied as usual to [merge] its entries. + */ @WorkflowUiExperimentalApi public operator fun ViewEnvironment.plus(registry: ViewRegistry): ViewEnvironment { if (this[ViewRegistry] === registry) return this diff --git a/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/CompositeViewRegistryTest.kt b/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/CompositeViewRegistryTest.kt index b3810c20f..6a7685d2f 100644 --- a/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/CompositeViewRegistryTest.kt +++ b/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/CompositeViewRegistryTest.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1.ui import com.google.common.truth.Truth.assertThat import com.squareup.workflow1.ui.ViewRegistry.Entry +import com.squareup.workflow1.ui.ViewRegistry.Key import org.junit.Test import kotlin.reflect.KClass import kotlin.test.assertFailsWith @@ -28,18 +29,18 @@ internal class CompositeViewRegistryTest { val bazFactory = TestEntry(BazRendering::class) val fooBarRegistry = TestRegistry( mapOf( - FooRendering::class to fooFactory, - BarRendering::class to barFactory + fooFactory.key to fooFactory, + barFactory.key to barFactory ) ) - val bazRegistry = TestRegistry(factories = mapOf(BazRendering::class to bazFactory)) + val bazRegistry = TestRegistry(factories = mapOf(bazFactory.key to bazFactory)) val registry = fooBarRegistry + bazRegistry - assertThat(registry.getEntryFor(FooRendering::class)) + assertThat(registry.getEntryFor(Key(FooRendering::class, TestEntry::class))) .isSameInstanceAs(fooFactory) - assertThat(registry.getEntryFor(BarRendering::class)) + assertThat(registry.getEntryFor(Key(BarRendering::class, TestEntry::class))) .isSameInstanceAs(barFactory) - assertThat(registry.getEntryFor(BazRendering::class)) + assertThat(registry.getEntryFor(Key(BazRendering::class, TestEntry::class))) .isSameInstanceAs(bazFactory) } @@ -47,7 +48,7 @@ internal class CompositeViewRegistryTest { val fooRegistry = TestRegistry(setOf(FooRendering::class)) val registry = CompositeViewRegistry(ViewRegistry(), fooRegistry) - assertThat(registry.getEntryFor(BarRendering::class)).isNull() + assertThat(registry.getEntryFor(Key(BarRendering::class, TestEntry::class))).isNull() } @Test fun `keys includes all composite registries' keys`() { @@ -56,28 +57,33 @@ internal class CompositeViewRegistryTest { val registry = CompositeViewRegistry(fooBarRegistry, bazRegistry) assertThat(registry.keys).containsExactly( - FooRendering::class, - BarRendering::class, - BazRendering::class + Key(FooRendering::class, TestEntry::class), + Key(BarRendering::class, TestEntry::class), + Key(BazRendering::class, TestEntry::class) ) } - private class TestEntry( - override val type: KClass - ) : Entry + private class TestEntry(type: KClass) : Entry { + override val key = Key(type, TestEntry::class) + } private object FooRendering private object BarRendering private object BazRendering - private class TestRegistry(private val factories: Map, Entry<*>>) : ViewRegistry { - constructor(keys: Set>) : this(keys.associateWith { TestEntry(it) }) + private class TestRegistry(private val factories: Map, Entry<*>>) : ViewRegistry { + constructor(keys: Set>) : this( + keys.associate { + val entry = TestEntry(it) + entry.key to entry + } + ) - override val keys: Set> get() = factories.keys + override val keys: Set> get() = factories.keys @Suppress("UNCHECKED_CAST") - override fun getEntryFor( - renderingType: KClass - ): Entry = factories.getValue(renderingType) as Entry + override fun getEntryFor( + key: Key + ): Entry = factories.getValue(key) as 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 9d5652983..d15ddfb30 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 @@ -2,14 +2,17 @@ package com.squareup.workflow1.ui import com.google.common.truth.Truth.assertThat import com.squareup.workflow1.ui.ViewEnvironment.Companion.EMPTY +import com.squareup.workflow1.ui.ViewRegistry.Key import org.junit.Test import kotlin.reflect.KClass @OptIn(WorkflowUiExperimentalApi::class) internal class EnvironmentScreenTest { private class TestFactory( - override val type: KClass - ) : ViewRegistry.Entry + type: KClass + ) : ViewRegistry.Entry { + override val key = Key(type, TestFactory::class) + } private data class TestValue(val value: String) { companion object : ViewEnvironmentKey() { @@ -29,11 +32,13 @@ internal class EnvironmentScreenTest { val viewRegistry = ViewRegistry(fooFactory) val envScreen = FooScreen.withRegistry(viewRegistry) - assertThat(envScreen.environment[ViewRegistry][FooScreen::class]) - .isSameInstanceAs(fooFactory) + assertThat( + envScreen.environment[ViewRegistry].getFactoryFor>(FooScreen) + ).isSameInstanceAs(fooFactory) - assertThat(envScreen.environment[ViewRegistry][BarScreen::class]) - .isNull() + assertThat( + envScreen.environment[ViewRegistry].getFactoryFor>(BarScreen) + ).isNull() } @Test fun `Screen withEnvironment works`() { @@ -43,10 +48,12 @@ internal class EnvironmentScreenTest { EMPTY + viewRegistry + TestValue("foo") ) - assertThat(envScreen.environment[ViewRegistry][FooScreen::class]) - .isSameInstanceAs(fooFactory) - assertThat(envScreen.environment[ViewRegistry][BarScreen::class]) - .isNull() + assertThat( + envScreen.environment[ViewRegistry].getFactoryFor>(FooScreen) + ).isSameInstanceAs(fooFactory) + assertThat( + envScreen.environment[ViewRegistry].getFactoryFor>(BarScreen) + ).isNull() assertThat(envScreen.environment[TestValue]) .isEqualTo(TestValue("foo")) } @@ -59,11 +66,13 @@ internal class EnvironmentScreenTest { val left = FooScreen.withRegistry(ViewRegistry(fooFactory1, barFactory)) val union = left.withRegistry(ViewRegistry(fooFactory2)) - assertThat(union.environment[ViewRegistry][FooScreen::class]) - .isSameInstanceAs(fooFactory2) + assertThat( + union.environment[ViewRegistry].getFactoryFor>(FooScreen) + ).isSameInstanceAs(fooFactory2) - assertThat(union.environment[ViewRegistry][BarScreen::class]) - .isSameInstanceAs(barFactory) + assertThat( + union.environment[ViewRegistry].getFactoryFor>(BarScreen) + ).isSameInstanceAs(barFactory) } @Test fun `EnvironmentScreen withEnvironment merges`() { @@ -79,10 +88,12 @@ internal class EnvironmentScreenTest { EMPTY + ViewRegistry(fooFactory2) + TestValue("right") ) - assertThat(union.environment[ViewRegistry][FooScreen::class]) - .isSameInstanceAs(fooFactory2) - assertThat(union.environment[ViewRegistry][BarScreen::class]) - .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")) } diff --git a/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/ViewRegistryTest.kt b/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/ViewRegistryTest.kt index 45b1b7c32..cbfdf0b15 100644 --- a/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/ViewRegistryTest.kt +++ b/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/ViewRegistryTest.kt @@ -3,6 +3,7 @@ package com.squareup.workflow1.ui import com.google.common.truth.Truth.assertThat import com.squareup.workflow1.ui.ViewEnvironment.Companion.EMPTY import com.squareup.workflow1.ui.ViewRegistry.Entry +import com.squareup.workflow1.ui.ViewRegistry.Key import org.junit.Test import kotlin.reflect.KClass import kotlin.test.assertFailsWith @@ -16,7 +17,7 @@ internal class ViewRegistryTest { val factory2 = TestEntry(BarRendering::class) val registry = ViewRegistry(factory1, factory2) - assertThat(registry.keys).containsExactly(factory1.type, factory2.type) + assertThat(registry.keys).containsExactly(factory1.key, factory2.key) } @Test fun `constructor throws on duplicates`() { @@ -36,7 +37,7 @@ internal class ViewRegistryTest { val fooFactory = TestEntry(FooRendering::class) val registry = ViewRegistry(fooFactory) - val factory = registry[FooRendering::class] + val factory = registry[Key(FooRendering::class, TestEntry::class)] assertThat(factory).isSameInstanceAs(fooFactory) } @@ -44,7 +45,7 @@ internal class ViewRegistryTest { val fooFactory = TestEntry(FooRendering::class) val registry = ViewRegistry(fooFactory) - assertThat(registry[BarRendering::class]).isNull() + assertThat(registry[Key(BarRendering::class, TestEntry::class)]).isNull() } @Test fun `ViewRegistry with no arguments infers type`() { @@ -57,7 +58,7 @@ internal class ViewRegistryTest { val factory2 = TestEntry(FooRendering::class) val merged = ViewRegistry(factory1) merge ViewRegistry(factory2) - assertThat(merged[FooRendering::class]).isSameInstanceAs(factory2) + assertThat(merged[Key(FooRendering::class, TestEntry::class)]).isSameInstanceAs(factory2) } @Test fun `ViewEnvironment plus ViewRegistry prefers new registry values`() { @@ -67,8 +68,9 @@ internal class ViewRegistryTest { val env = EMPTY + ViewRegistry(leftBar) val merged = env + ViewRegistry(rightBar, TestEntry(FooRendering::class)) - assertThat(merged[ViewRegistry][BarRendering::class]).isSameInstanceAs(rightBar) - assertThat(merged[ViewRegistry][FooRendering::class]).isNotNull() + assertThat(merged[ViewRegistry][Key(BarRendering::class, TestEntry::class)]) + .isSameInstanceAs(rightBar) + assertThat(merged[ViewRegistry][Key(FooRendering::class, TestEntry::class)]).isNotNull() } @Test fun `ViewEnvironment plus ViewEnvironment prefers right ViewRegistry`() { @@ -79,8 +81,9 @@ internal class ViewRegistryTest { val rightEnv = EMPTY + ViewRegistry(rightBar, TestEntry(FooRendering::class)) val merged = leftEnv + rightEnv - assertThat(merged[ViewRegistry][BarRendering::class]).isSameInstanceAs(rightBar) - assertThat(merged[ViewRegistry][FooRendering::class]).isNotNull() + assertThat(merged[ViewRegistry][Key(BarRendering::class, TestEntry::class)]) + .isSameInstanceAs(rightBar) + assertThat(merged[ViewRegistry][Key(FooRendering::class, TestEntry::class)]).isNotNull() } @Test fun `plus of empty returns this`() { @@ -127,8 +130,10 @@ internal class ViewRegistryTest { } private class TestEntry( - override val type: KClass - ) : Entry + type: KClass + ) : Entry { + override val key = Key(type, TestEntry::class) + } private object FooRendering private object BarRendering