From e5b5ba9e8aac6c0ddc7326d16635d6c7f5bc18f8 Mon Sep 17 00:00:00 2001 From: Ray Ryan Date: Mon, 28 Feb 2022 09:50:57 -0800 Subject: [PATCH] Overhaul of Compose support. I had originally thought about creating a new `ScreenComposableFactory : ViewRegistry.Entry`, but that did nothing but introduce complexity. The existing system has the elegant property that every `ViewFactory` can produce either a `@Composable` or a `View` as needed. That's a crucial trait, and it's most easily maintained by teaching the same trick to `ScreenViewFactory`. Where possible, existing tests are copied into new files with a `Legacy` prefix and otherwise unchanged, so that diffs of the original tests will be reviewable. --- gradle.properties | 2 +- .../compose/hellocompose/HelloBinding.kt | 4 +- .../compose/hellocompose/HelloWorkflow.kt | 5 +- .../hellocomposebinding/HelloBinding.kt | 4 +- .../hellocomposebinding/HelloWorkflow.kt | 5 +- .../hellocomposeworkflow/ComposeWorkflow.kt | 14 +- .../ComposeWorkflowImpl.kt | 10 +- .../hellocomposeworkflow/HelloWorkflow.kt | 6 +- .../InlineRenderingWorkflow.kt | 9 +- .../compose/nestedrenderings/LegacyRunner.kt | 14 +- .../nestedrenderings/RecursiveViewFactory.kt | 11 +- .../nestedrenderings/RecursiveWorkflow.kt | 11 +- .../nestedrenderings/StringRendering.kt | 7 + .../sample/compose/preview/PreviewActivity.kt | 51 +- .../squareup/sample/compose/textinput/App.kt | 1 - .../compose/textinput/TextInputViewFactory.kt | 4 +- .../compose/textinput/TextInputWorkflow.kt | 3 +- .../panel/PanelOverlayDialogFactory.kt | 3 +- .../squareup/sample/dungeon/LoadingBinding.kt | 1 - .../squareup/workflow1/StatefulWorkflow.kt | 2 +- .../squareup/workflow1/StatelessWorkflow.kt | 2 +- .../workflow1/internal/WorkflowNodeTest.kt | 2 +- .../squareup/workflow1/testing/WorkerSink.kt | 1 - .../squareup/workflow1/WorkerStressTest.kt | 1 - .../java/com/squareup/workflow1/WorkerTest.kt | 2 +- .../tracing/TracingWorkflowInterceptor.kt | 2 - .../compose-tooling/api/compose-tooling.api | 15 +- .../tooling/LegacyPreviewViewFactoryTest.kt | 174 +++++ .../compose/tooling/PreviewViewFactoryTest.kt | 66 +- .../ui/compose/tooling/LegacyViewFactories.kt | 70 ++ .../compose/tooling/PlaceholderViewFactory.kt | 57 +- .../compose/tooling/PreviewViewEnvironment.kt | 57 +- .../ui/compose/tooling/ViewFactories.kt | 30 +- workflow-ui/compose/api/compose.api | 33 +- .../ui/compose/ComposeViewFactoryTest.kt | 29 +- .../compose/ComposeViewTreeIntegrationTest.kt | 236 +++---- .../compose/LegacyComposeViewFactoryTest.kt | 144 ++++ .../LegacyComposeViewTreeIntegrationTest.kt | 635 ++++++++++++++++++ .../ui/compose/LegacyWorkflowRenderingTest.kt | 580 ++++++++++++++++ .../compose/NoTransitionBackStackContainer.kt | 17 +- .../ui/compose/WorkflowRenderingTest.kt | 65 +- .../workflow1/ui/compose/ComposeRendering.kt | 5 + .../workflow1/ui/compose/ComposeScreen.kt | 81 +++ .../ui/compose/ComposeScreenViewFactory.kt | 145 ++++ .../ui/compose/ComposeViewFactory.kt | 8 + .../workflow1/ui/compose/CompositionRoot.kt | 76 ++- .../ui/compose/LegacyWorkflowRendering.kt | 185 +++++ .../workflow1/ui/compose/WorkflowRendering.kt | 29 +- workflow-ui/core-android/api/core-android.api | 2 +- .../workflow1/ui/ScreenViewFactoryFinder.kt | 2 +- .../ModalScreenOverlayDialogFactory.kt | 9 +- .../com/squareup/workflow1/ui/AsScreen.kt | 1 + .../com/squareup/workflow1/ui/ViewRegistry.kt | 2 - .../workflow1/ui/CompositeViewRegistryTest.kt | 1 - .../test/AbstractLifecycleTestActivity.kt | 2 - 55 files changed, 2582 insertions(+), 351 deletions(-) create mode 100644 samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/StringRendering.kt create mode 100644 workflow-ui/compose-tooling/src/androidTest/java/com/squareup/workflow1/ui/compose/tooling/LegacyPreviewViewFactoryTest.kt create mode 100644 workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/LegacyViewFactories.kt create mode 100644 workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyComposeViewFactoryTest.kt create mode 100644 workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyComposeViewTreeIntegrationTest.kt create mode 100644 workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyWorkflowRenderingTest.kt create mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt create mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreenViewFactory.kt create mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/LegacyWorkflowRendering.kt diff --git a/gradle.properties b/gradle.properties index 192baf5f6b..b4a519be60 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ android.useAndroidX=true systemProp.org.gradle.internal.publish.checksums.insecure=true GROUP=com.squareup.workflow1 -VERSION_NAME=1.7.0-uiUpdate01-SNAPSHOT +VERSION_NAME=1.7.0-uiUpdate02-SNAPSHOT POM_DESCRIPTION=Square Workflow 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 index 067d306796..0309a095b4 100644 --- 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 @@ -8,10 +8,10 @@ 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.composeViewFactory +import com.squareup.workflow1.ui.compose.composeScreenViewFactory @OptIn(WorkflowUiExperimentalApi::class) -val HelloBinding = composeViewFactory { rendering, _ -> +val HelloBinding = composeScreenViewFactory { rendering, _ -> Text( rendering.message, modifier = Modifier 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/HelloWorkflow.kt index d446a099af..16bc8142b9 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/HelloWorkflow.kt @@ -8,6 +8,8 @@ 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() { enum class State { @@ -20,10 +22,11 @@ object HelloWorkflow : StatefulWorkflow() { } } + @OptIn(WorkflowUiExperimentalApi::class) data class Rendering( val message: String, val onClick: () -> Unit - ) + ) : Screen private val helloAction = action { state = state.theOtherState() 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 ff07561e89..ec3afba77f 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.composeViewFactory +import com.squareup.workflow1.ui.compose.composeScreenViewFactory import com.squareup.workflow1.ui.compose.tooling.Preview @OptIn(WorkflowUiExperimentalApi::class) -val HelloBinding = composeViewFactory { rendering, _ -> +val HelloBinding = composeScreenViewFactory { rendering, _ -> Text( rendering.message, modifier = Modifier diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloWorkflow.kt index 2ea5826397..55555899e4 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloWorkflow.kt @@ -8,6 +8,8 @@ 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() { enum class State { @@ -20,10 +22,11 @@ object HelloWorkflow : StatefulWorkflow() { } } + @OptIn(WorkflowUiExperimentalApi::class) data class Rendering( val message: String, val onClick: () -> Unit - ) + ) : Screen private val helloAction = action { state = state.theOtherState() diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflow.kt index 248cfffde3..da39ae62f8 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflow.kt @@ -7,11 +7,11 @@ import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.ComposeRendering +import com.squareup.workflow1.ui.compose.ComposeScreen /** * A stateless [Workflow] that [renders][RenderingContent] itself as a [Composable] function. - * Effectively defines an inline [ComposeRendering]. + * Effectively defines an inline [ComposeScreen]. * * This workflow does not have access to a [RenderContext] since render contexts are only valid * during render passes, and this workflow's [RenderingContent] method is invoked after the render @@ -28,8 +28,8 @@ import com.squareup.workflow1.ui.compose.ComposeRendering * comes up. */ @WorkflowUiExperimentalApi -public abstract class ComposeWorkflow : - Workflow { +abstract class ComposeWorkflow : + Workflow { /** * Renders [props] by emitting Compose UI. This function will be called to update the UI whenever @@ -40,13 +40,13 @@ public abstract class ComposeWorkflow : * workflow's parent. * @param viewEnvironment The [ViewEnvironment] passed down through the `ViewBinding` pipeline. */ - @Composable public abstract fun RenderingContent( + @Composable abstract fun RenderingContent( props: PropsT, outputSink: Sink, viewEnvironment: ViewEnvironment ) - override fun asStatefulWorkflow(): StatefulWorkflow = + override fun asStatefulWorkflow(): StatefulWorkflow = ComposeWorkflowImpl(this) } @@ -54,7 +54,7 @@ public abstract class ComposeWorkflow : * Returns a [ComposeWorkflow] that renders itself using the given [render] function. */ @WorkflowUiExperimentalApi -public inline fun Workflow.Companion.composed( +inline fun Workflow.Companion.composed( crossinline render: @Composable ( props: PropsT, outputSink: Sink, diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflowImpl.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflowImpl.kt index 25d3669372..3a649575c9 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflowImpl.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflowImpl.kt @@ -12,12 +12,12 @@ import com.squareup.workflow1.action import com.squareup.workflow1.contraMap import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.ComposeRendering +import com.squareup.workflow1.ui.compose.ComposeScreen @WorkflowUiExperimentalApi internal class ComposeWorkflowImpl( private val workflow: ComposeWorkflow -) : StatefulWorkflow, OutputT, ComposeRendering>() { +) : StatefulWorkflow, OutputT, ComposeScreen>() { // This doesn't need to be a @Model, it only gets set once, before the composable ever runs. class SinkHolder(var sink: Sink? = null) @@ -25,7 +25,7 @@ internal class ComposeWorkflowImpl( data class State( val propsHolder: MutableState, val sinkHolder: SinkHolder, - val rendering: ComposeRendering + val rendering: ComposeScreen ) override fun initialState( @@ -38,7 +38,7 @@ internal class ComposeWorkflowImpl( return State( propsHolder, sinkHolder, - object : ComposeRendering { + object : ComposeScreen { @Composable override fun Content(viewEnvironment: ViewEnvironment) { // The sink will get set on the first render pass, which must happen before this is first // composed, so it should never be null. @@ -63,7 +63,7 @@ internal class ComposeWorkflowImpl( renderProps: PropsT, renderState: State, context: RenderContext - ): ComposeRendering { + ): ComposeScreen { // The first render pass needs to cache the sink. The sink is reusable, so we can just pass the // same one every time. if (renderState.sinkHolder.sink == null) { diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloWorkflow.kt index 518fa07c96..74560385f6 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloWorkflow.kt @@ -8,14 +8,14 @@ import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.action import com.squareup.workflow1.parse import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.ComposeRendering +import com.squareup.workflow1.ui.compose.ComposeScreen /** * The root workflow of this sample. Manges the current toggle state and passes it to * [HelloComposeWorkflow]. */ @OptIn(WorkflowUiExperimentalApi::class) -object HelloWorkflow : StatefulWorkflow() { +object HelloWorkflow : StatefulWorkflow() { enum class State { Hello, Goodbye; @@ -40,7 +40,7 @@ object HelloWorkflow : StatefulWorkflow( renderProps: Unit, renderState: State, context: RenderContext - ): ComposeRendering = + ): ComposeScreen = context.renderChild(HelloComposeWorkflow, renderState.name) { helloAction } override fun snapshotState(state: State): Snapshot = Snapshot.of(if (state == Hello) 1 else 0) 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 5c3a393a58..1772ccda47 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 @@ -1,4 +1,3 @@ -@file:Suppress("DEPRECATION", "FunctionName") @file:OptIn(WorkflowUiExperimentalApi::class) package com.squareup.sample.compose.inlinerendering @@ -20,14 +19,14 @@ import androidx.compose.ui.tooling.preview.Preview import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.parse -import com.squareup.workflow1.ui.AndroidViewRendering +import com.squareup.workflow1.ui.AndroidScreen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.ComposeRendering +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, @@ -38,7 +37,7 @@ object InlineRenderingWorkflow : StatefulWorkflow = ComposeRendering { + ): AndroidScreen<*> = ComposeScreen { Box { Button(onClick = context.eventHandler { state += 1 }) { Text("Counter: ") 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 bb56b5ad75..67da3ee1ef 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 @@ -8,18 +8,18 @@ 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.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import com.squareup.workflow1.ui.ScreenViewRunner.Companion.bind import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.tooling.Preview /** - * A [LayoutRunner] that renders [LegacyRendering]s using the legacy view framework. + * A [ScreenViewRunner] that renders [LegacyRendering]s using the legacy view framework. */ @OptIn(WorkflowUiExperimentalApi::class) -class LegacyRunner(private val binding: LegacyViewBinding) : LayoutRunner { +class LegacyRunner(private val binding: LegacyViewBinding) : ScreenViewRunner { override fun showRendering( rendering: LegacyRendering, @@ -28,7 +28,7 @@ class LegacyRunner(private val binding: LegacyViewBinding) : LayoutRunner by bind( + companion object : ScreenViewFactory by bind( LegacyViewBinding::inflate, ::LegacyRunner ) } @@ -37,7 +37,7 @@ class LegacyRunner(private val binding: LegacyViewBinding) : LayoutRunner { error("No background colo * A `ViewFactory` that renders [RecursiveWorkflow.Rendering]s. */ @OptIn(WorkflowUiExperimentalApi::class) -val RecursiveViewFactory = composeViewFactory { rendering, viewEnvironment -> +val RecursiveViewFactory = composeScreenViewFactory { rendering, viewEnvironment -> // Every child should be drawn with a slightly-darker background color. val color = LocalBackgroundColor.current val childColor = remember(color) { @@ -75,9 +76,9 @@ val RecursiveViewFactory = composeViewFactory { rendering, viewEnviro RecursiveViewFactory.Preview( Rendering( children = listOf( - "foo", + StringRendering("foo"), Rendering( - children = listOf("bar"), + children = listOf(StringRendering("bar")), onAddChildClicked = {}, onResetClicked = {} ) ), @@ -90,7 +91,7 @@ val RecursiveViewFactory = composeViewFactory { rendering, viewEnviro @OptIn(WorkflowUiExperimentalApi::class) @Composable private fun Children( - children: List, + children: List, viewEnvironment: ViewEnvironment, modifier: Modifier ) { 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 0136126d4d..172452a086 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 @@ -7,6 +7,8 @@ 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.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi /** * A simple workflow that produces [Rendering]s of zero or more children. @@ -16,7 +18,8 @@ import com.squareup.workflow1.renderChild * to force it to go through the legacy view layer. This way this sample both demonstrates pass- * through Composable renderings as well as adapting in both directions. */ -object RecursiveWorkflow : StatefulWorkflow() { +@OptIn(WorkflowUiExperimentalApi::class) +object RecursiveWorkflow : StatefulWorkflow() { data class State(val children: Int = 0) @@ -28,15 +31,15 @@ object RecursiveWorkflow : StatefulWorkflow() { * @param onResetClicked Resets [children] to an empty list. */ data class Rendering( - val children: List, + val children: List, val onAddChildClicked: () -> Unit, val onResetClicked: () -> Unit - ) + ) : Screen /** * Wrapper around a [Rendering] that will be implemented using a legacy view. */ - data class LegacyRendering(val rendering: Any) + data class LegacyRendering(val rendering: Screen) : Screen override fun initialState( props: Unit, diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/StringRendering.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/StringRendering.kt new file mode 100644 index 0000000000..c2fc86862e --- /dev/null +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/StringRendering.kt @@ -0,0 +1,7 @@ +package com.squareup.sample.compose.nestedrenderings + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +@OptIn(WorkflowUiExperimentalApi::class) +data class StringRendering(val value: String) : Screen 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 8fbce323a7..f2578e0761 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 @@ -1,7 +1,4 @@ -@file:OptIn( - WorkflowUiExperimentalApi::class, - WorkflowUiExperimentalApi::class, -) +@file:OptIn(WorkflowUiExperimentalApi::class, WorkflowUiExperimentalApi::class) package com.squareup.sample.compose.preview @@ -23,9 +20,10 @@ import androidx.compose.ui.Modifier 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.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.WorkflowRendering -import com.squareup.workflow1.ui.compose.composeViewFactory +import com.squareup.workflow1.ui.compose.composeScreenViewFactory import com.squareup.workflow1.ui.compose.tooling.Preview class PreviewActivity : AppCompatActivity() { @@ -58,32 +56,33 @@ val previewContactRendering = ContactRendering( data class ContactRendering( val name: String, val details: ContactDetailsRendering -) +) : Screen data class ContactDetailsRendering( val phoneNumber: String, val address: String -) +) : Screen -private val contactViewFactory = composeViewFactory { rendering, environment -> - Card( - modifier = Modifier - .padding(8.dp) - .clickable { /* handle click */ } - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = spacedBy(8.dp), +private val contactViewFactory = + composeScreenViewFactory { rendering, environment -> + Card( + modifier = Modifier + .padding(8.dp) + .clickable { /* handle click */ } ) { - 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) - ) + 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) + ) + } } } -} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/App.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/App.kt index 7a3be9e907..18a182bac5 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/App.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/App.kt @@ -1,5 +1,4 @@ @file:OptIn(WorkflowUiExperimentalApi::class) -@file:Suppress("FunctionName") package com.squareup.sample.compose.textinput 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 851f8c7162..734811ffb5 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt @@ -20,11 +20,11 @@ import com.squareup.sample.compose.textinput.TextInputWorkflow.Rendering import com.squareup.workflow1.ui.TextController import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.asMutableState -import com.squareup.workflow1.ui.compose.composeViewFactory +import com.squareup.workflow1.ui.compose.composeScreenViewFactory import com.squareup.workflow1.ui.compose.tooling.Preview @OptIn(WorkflowUiExperimentalApi::class) -val TextInputViewFactory = composeViewFactory { rendering, _ -> +val TextInputViewFactory = composeScreenViewFactory { rendering, _ -> Column( modifier = Modifier .fillMaxSize() diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputWorkflow.kt index b3e754d2c7..f3772c2bdb 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputWorkflow.kt @@ -5,6 +5,7 @@ import com.squareup.sample.compose.textinput.TextInputWorkflow.State import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.action +import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.TextController import com.squareup.workflow1.ui.WorkflowUiExperimentalApi @@ -20,7 +21,7 @@ object TextInputWorkflow : StatefulWorkflow() { data class Rendering( val textController: TextController, val onSwapText: () -> Unit - ) + ) : Screen private val swapText = action { state = state.copy(showingTextA = !state.showingTextA) diff --git a/samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelOverlayDialogFactory.kt b/samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelOverlayDialogFactory.kt index 6a697a7e20..efb8b7898f 100644 --- a/samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelOverlayDialogFactory.kt +++ b/samples/containers/android/src/main/java/com/squareup/sample/container/panel/PanelOverlayDialogFactory.kt @@ -8,7 +8,6 @@ import android.view.View import com.squareup.sample.container.R import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.container.ModalScreenOverlayDialogFactory -import com.squareup.workflow1.ui.container.setBounds /** * Android support for [PanelOverlay]. @@ -60,6 +59,6 @@ internal object PanelOverlayDialogFactory : ModalScreenOverlayDialogFactory LoadingBinding( @StringRes loadingLabelRes: Int ): ScreenViewFactory = diff --git a/workflow-core/src/main/java/com/squareup/workflow1/StatefulWorkflow.kt b/workflow-core/src/main/java/com/squareup/workflow1/StatefulWorkflow.kt index 57edfd50fe..a90ffc1b97 100644 --- a/workflow-core/src/main/java/com/squareup/workflow1/StatefulWorkflow.kt +++ b/workflow-core/src/main/java/com/squareup/workflow1/StatefulWorkflow.kt @@ -153,7 +153,7 @@ public abstract class StatefulWorkflow< /** * Creates a `RenderContext` from a [BaseRenderContext] for the given [StatefulWorkflow]. */ -@Suppress("UNCHECKED_CAST", "FunctionName") +@Suppress("UNCHECKED_CAST") public fun RenderContext( baseContext: BaseRenderContext, workflow: StatefulWorkflow diff --git a/workflow-core/src/main/java/com/squareup/workflow1/StatelessWorkflow.kt b/workflow-core/src/main/java/com/squareup/workflow1/StatelessWorkflow.kt index be8bffd570..47552a7fc2 100644 --- a/workflow-core/src/main/java/com/squareup/workflow1/StatelessWorkflow.kt +++ b/workflow-core/src/main/java/com/squareup/workflow1/StatelessWorkflow.kt @@ -68,7 +68,7 @@ public abstract class StatelessWorkflow /** * Creates a `RenderContext` from a [BaseRenderContext] for the given [StatelessWorkflow]. */ -@Suppress("UNCHECKED_CAST", "FunctionName") +@Suppress("UNCHECKED_CAST") public fun RenderContext( baseContext: BaseRenderContext, workflow: StatelessWorkflow diff --git a/workflow-runtime/src/test/java/com/squareup/workflow1/internal/WorkflowNodeTest.kt b/workflow-runtime/src/test/java/com/squareup/workflow1/internal/WorkflowNodeTest.kt index f2da7651e5..59cb61e2cb 100644 --- a/workflow-runtime/src/test/java/com/squareup/workflow1/internal/WorkflowNodeTest.kt +++ b/workflow-runtime/src/test/java/com/squareup/workflow1/internal/WorkflowNodeTest.kt @@ -1,4 +1,4 @@ -@file:Suppress("EXPERIMENTAL_API_USAGE", "DEPRECATION") +@file:Suppress("EXPERIMENTAL_API_USAGE") package com.squareup.workflow1.internal diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkerSink.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkerSink.kt index eed5eb3ac7..f569b12da1 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkerSink.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkerSink.kt @@ -54,5 +54,4 @@ public class WorkerSink( override fun toString(): String = "${super.toString()}<$type>(name=\"$name\")" } -@Suppress("FunctionName") public inline fun WorkerSink(name: String): WorkerSink = WorkerSink(name, T::class) diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/WorkerStressTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/WorkerStressTest.kt index 3117567104..86fb41552f 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/WorkerStressTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/WorkerStressTest.kt @@ -69,7 +69,6 @@ internal class WorkerStressTest { @Test fun `multiple subscriptions to single StateFlow when emits`() { val flow = MutableStateFlow(Unit) - @Suppress("DEPRECATION") val workers = List(WORKER_COUNT) { flow.asWorker() } val action = action { setOutput(1) } val workflow = Workflow.stateless { diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/WorkerTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/WorkerTest.kt index d85aade12a..4f9e847c2c 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/WorkerTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/WorkerTest.kt @@ -11,7 +11,7 @@ import kotlin.test.assertNotSame import kotlin.test.assertTrue /** Worker tests that use the [Worker.test] function. Core tests are in the core module. */ -class WorkerTest { +internal class WorkerTest { private class ExpectedException : RuntimeException() diff --git a/workflow-tracing/src/main/java/com/squareup/workflow1/diagnostic/tracing/TracingWorkflowInterceptor.kt b/workflow-tracing/src/main/java/com/squareup/workflow1/diagnostic/tracing/TracingWorkflowInterceptor.kt index 059e9e1605..d721cc1375 100644 --- a/workflow-tracing/src/main/java/com/squareup/workflow1/diagnostic/tracing/TracingWorkflowInterceptor.kt +++ b/workflow-tracing/src/main/java/com/squareup/workflow1/diagnostic/tracing/TracingWorkflowInterceptor.kt @@ -39,7 +39,6 @@ import kotlin.reflect.KType * @param name If non-empty, will be used to set the "process name" in the trace file. If empty, * the workflow type is used for the process name. */ -@Suppress("FunctionName") public fun TracingWorkflowInterceptor( file: File, name: String = "" @@ -59,7 +58,6 @@ public fun TracingWorkflowInterceptor( * @param encoderProvider A function that returns a [TraceEncoder] that will be used to write trace * events. The function gets the [CoroutineScope] that the workflow runtime is running in. */ -@Suppress("FunctionName") public fun TracingWorkflowInterceptor( name: String = "", memoryStats: MemoryStats = RuntimeMemoryStats, diff --git a/workflow-ui/compose-tooling/api/compose-tooling.api b/workflow-ui/compose-tooling/api/compose-tooling.api index f6394adff0..303829cb8f 100644 --- a/workflow-ui/compose-tooling/api/compose-tooling.api +++ b/workflow-ui/compose-tooling/api/compose-tooling.api @@ -1,3 +1,10 @@ +public final class com/squareup/workflow1/ui/compose/tooling/ComposableSingletons$LegacyViewFactoriesKt { + public static final field INSTANCE Lcom/squareup/workflow1/ui/compose/tooling/ComposableSingletons$LegacyViewFactoriesKt; + 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/ComposableSingletons$ViewFactoriesKt { public static final field INSTANCE Lcom/squareup/workflow1/ui/compose/tooling/ComposableSingletons$ViewFactoriesKt; public static field lambda-1 Lkotlin/jvm/functions/Function4; @@ -5,15 +12,21 @@ public final class com/squareup/workflow1/ui/compose/tooling/ComposableSingleton public final fun getLambda-1$wf1_compose_tooling ()Lkotlin/jvm/functions/Function4; } +public final class com/squareup/workflow1/ui/compose/tooling/LegacyViewFactoriesKt { + public static final fun Preview (Lcom/squareup/workflow1/ui/ViewFactory;Ljava/lang/Object;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + public final class com/squareup/workflow1/ui/compose/tooling/PlaceholderViewFactoryKt { + public static final fun placeholderScreenViewFactory (Landroidx/compose/ui/Modifier;)Lcom/squareup/workflow1/ui/ScreenViewFactory; public static final fun placeholderViewFactory (Landroidx/compose/ui/Modifier;)Lcom/squareup/workflow1/ui/ViewFactory; } public final class com/squareup/workflow1/ui/compose/tooling/PreviewViewEnvironmentKt { + public static final fun previewViewEnvironment (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/ui/ScreenViewFactory;Landroidx/compose/runtime/Composer;II)Lcom/squareup/workflow1/ui/ViewEnvironment; public static final fun previewViewEnvironment (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/ui/ViewFactory;Landroidx/compose/runtime/Composer;II)Lcom/squareup/workflow1/ui/ViewEnvironment; } public final class com/squareup/workflow1/ui/compose/tooling/ViewFactoriesKt { - public static final fun Preview (Lcom/squareup/workflow1/ui/ViewFactory;Ljava/lang/Object;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 } diff --git a/workflow-ui/compose-tooling/src/androidTest/java/com/squareup/workflow1/ui/compose/tooling/LegacyPreviewViewFactoryTest.kt b/workflow-ui/compose-tooling/src/androidTest/java/com/squareup/workflow1/ui/compose/tooling/LegacyPreviewViewFactoryTest.kt new file mode 100644 index 0000000000..b78972f5b7 --- /dev/null +++ b/workflow-ui/compose-tooling/src/androidTest/java/com/squareup/workflow1/ui/compose/tooling/LegacyPreviewViewFactoryTest.kt @@ -0,0 +1,174 @@ +@file:Suppress("TestFunctionName", "PrivatePropertyName", "DEPRECATION") + +package com.squareup.workflow1.ui.compose.tooling + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.WorkflowRendering +import com.squareup.workflow1.ui.compose.composeViewFactory +import com.squareup.workflow1.ui.internal.test.DetectLeaksAfterTestSuccess +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +@OptIn(WorkflowUiExperimentalApi::class) +@RunWith(AndroidJUnit4::class) +internal class LegacyPreviewViewFactoryTest { + + private val composeRule = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + @Test fun singleChild() { + composeRule.setContent { + ParentWithOneChildPreview() + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun twoChildren() { + composeRule.setContent { + ParentWithTwoChildrenPreview() + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + composeRule.onNodeWithText("three").assertIsDisplayed() + } + + @Test fun recursive() { + composeRule.setContent { + ParentRecursivePreview() + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + composeRule.onNodeWithText("three").assertIsDisplayed() + } + + @Test fun modifierIsApplied() { + composeRule.setContent { + ParentWithModifier() + } + + // The view factory will be rendered with size (0,0), so it should be reported as not displayed. + composeRule.onNodeWithText("one").assertIsNotDisplayed() + composeRule.onNodeWithText("two").assertIsNotDisplayed() + } + + @Test fun placeholderModifierIsApplied() { + composeRule.setContent { + ParentWithPlaceholderModifier() + } + + // The child will be rendered with size (0,0), so it should be reported as not displayed. + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsNotDisplayed() + } + + @Test fun customViewEnvironment() { + composeRule.setContent { + ParentConsumesCustomKeyPreview() + } + + composeRule.onNodeWithText("foo").assertIsDisplayed() + } + + private val ParentWithOneChild = + composeViewFactory> { rendering, environment -> + Column { + BasicText(rendering.first) + WorkflowRendering(rendering.second, environment) + } + } + + @Preview @Composable private fun ParentWithOneChildPreview() { + ParentWithOneChild.Preview(Pair("one", "two")) + } + + private val ParentWithTwoChildren = + composeViewFactory> { rendering, environment -> + Column { + WorkflowRendering(rendering.first, environment) + BasicText(rendering.second) + WorkflowRendering(rendering.third, environment) + } + } + + @Preview @Composable private fun ParentWithTwoChildrenPreview() { + ParentWithTwoChildren.Preview(Triple("one", "two", "three")) + } + + data class RecursiveRendering( + val text: String, + val child: RecursiveRendering? = null + ) + + private val ParentRecursive = composeViewFactory { rendering, environment -> + Column { + BasicText(rendering.text) + rendering.child?.let { child -> + WorkflowRendering(rendering = child, viewEnvironment = environment) + } + } + } + + @Preview @Composable private fun ParentRecursivePreview() { + ParentRecursive.Preview( + RecursiveRendering( + text = "one", + child = RecursiveRendering( + text = "two", + child = RecursiveRendering(text = "three") + ) + ) + ) + } + + @Preview @Composable private fun ParentWithModifier() { + ParentWithOneChild.Preview( + Pair("one", "two"), + modifier = Modifier.size(0.dp) + ) + } + + @Preview @Composable private fun ParentWithPlaceholderModifier() { + ParentWithOneChild.Preview( + Pair("one", "two"), + placeholderModifier = Modifier.size(0.dp) + ) + } + + object TestEnvironmentKey : ViewEnvironmentKey(String::class) { + override val default: String get() = error("Not specified") + } + + private val ParentConsumesCustomKey = composeViewFactory { _, environment -> + BasicText(environment[TestEnvironmentKey]) + } + + @Preview @Composable private fun ParentConsumesCustomKeyPreview() { + ParentConsumesCustomKey.Preview(Unit) { + it + (TestEnvironmentKey to "foo") + } + } +} 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 c3c7c13cb7..80a6e6b050 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 @@ -14,10 +14,11 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp 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.WorkflowRendering -import com.squareup.workflow1.ui.compose.composeViewFactory +import com.squareup.workflow1.ui.compose.composeScreenViewFactory import com.squareup.workflow1.ui.internal.test.DetectLeaksAfterTestSuccess import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule @@ -94,43 +95,72 @@ internal class PreviewViewFactoryTest { } private val ParentWithOneChild = - composeViewFactory> { rendering, environment -> + composeScreenViewFactory { rendering, environment -> Column { - BasicText(rendering.first) + BasicText(rendering.first.text) WorkflowRendering(rendering.second, environment) } } @Preview @Composable private fun ParentWithOneChildPreview() { - ParentWithOneChild.Preview(Pair("one", "two")) + ParentWithOneChild.Preview(TwoStrings("one", "two")) } private val ParentWithTwoChildren = - composeViewFactory> { rendering, environment -> + composeScreenViewFactory { rendering, environment -> Column { WorkflowRendering(rendering.first, environment) - BasicText(rendering.second) + BasicText(rendering.second.text) WorkflowRendering(rendering.third, environment) } } @Preview @Composable private fun ParentWithTwoChildrenPreview() { - ParentWithTwoChildren.Preview(Triple("one", "two", "three")) + ParentWithTwoChildren.Preview(ThreeStrings("one", "two", "three")) + } + + class Leaf(val text: String) : Screen { + override fun equals(other: Any?): Boolean = (other as? Leaf)?.text == text + override fun hashCode(): Int = text.hashCode() + override fun toString(): String = text + } + + data class TwoStrings( + val first: Leaf, + val second: Leaf + ) : Screen { + constructor( + first: String, + second: String + ) : this(Leaf(first), Leaf(second)) + } + + data class ThreeStrings( + val first: Leaf, + val second: Leaf, + val third: Leaf + ) : Screen { + constructor( + first: String, + second: String, + third: String + ) : this(Leaf(first), Leaf(second), Leaf(third)) } data class RecursiveRendering( val text: String, val child: RecursiveRendering? = null - ) + ) : Screen - private val ParentRecursive = composeViewFactory { rendering, environment -> - Column { - BasicText(rendering.text) - rendering.child?.let { child -> - WorkflowRendering(rendering = child, viewEnvironment = environment) + private val ParentRecursive = + composeScreenViewFactory { rendering, environment -> + Column { + BasicText(rendering.text) + rendering.child?.let { child -> + WorkflowRendering(rendering = child, viewEnvironment = environment) + } } } - } @Preview @Composable private fun ParentRecursivePreview() { ParentRecursive.Preview( @@ -146,14 +176,14 @@ internal class PreviewViewFactoryTest { @Preview @Composable private fun ParentWithModifier() { ParentWithOneChild.Preview( - Pair("one", "two"), + TwoStrings("one", "two"), modifier = Modifier.size(0.dp) ) } @Preview @Composable private fun ParentWithPlaceholderModifier() { ParentWithOneChild.Preview( - Pair("one", "two"), + TwoStrings("one", "two"), placeholderModifier = Modifier.size(0.dp) ) } @@ -162,12 +192,12 @@ internal class PreviewViewFactoryTest { override val default: String get() = error("Not specified") } - private val ParentConsumesCustomKey = composeViewFactory { _, environment -> + private val ParentConsumesCustomKey = composeScreenViewFactory { _, environment -> BasicText(environment[TestEnvironmentKey]) } @Preview @Composable private fun ParentConsumesCustomKeyPreview() { - ParentConsumesCustomKey.Preview(Unit) { + ParentConsumesCustomKey.Preview(TwoStrings("ignored", "ignored")) { it + (TestEnvironmentKey to "foo") } } diff --git a/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/LegacyViewFactories.kt b/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/LegacyViewFactories.kt new file mode 100644 index 0000000000..880c827a74 --- /dev/null +++ b/workflow-ui/compose-tooling/src/main/java/com/squareup/workflow1/ui/compose/tooling/LegacyViewFactories.kt @@ -0,0 +1,70 @@ +@file:Suppress("DEPRECATION") +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.ViewEnvironment +import com.squareup.workflow1.ui.ViewFactory +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.composeViewFactory + +/** + * Draws this [ViewFactory] using a special preview [ViewRegistry]. + * + * Use inside `@Preview` Composable functions. + * + * *Note: [rendering] must be the same type as this [ViewFactory], 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 [ViewFactory]. + * @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. + */ +@Deprecated("Use ScreenViewFactory.Preview") +@WorkflowUiExperimentalApi +@Composable public fun ViewFactory.Preview( + rendering: RenderingT, + modifier: Modifier = Modifier, + placeholderModifier: Modifier = Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null +) { + val previewEnvironment = + previewViewEnvironment(placeholderModifier, viewEnvironmentUpdater, mainFactory = this) + WorkflowRendering(rendering, previewEnvironment, modifier) +} + +@OptIn(WorkflowUiExperimentalApi::class) +@Preview(showBackground = true) +@Composable private fun ViewFactoryPreviewPreview() { + val factory = composeViewFactory { _, environment -> + Column( + verticalArrangement = spacedBy(8.dp), + modifier = Modifier.padding(8.dp) + ) { + BasicText("Top text") + WorkflowRendering( + rendering = "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(Unit) +} 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 050cd03d94..2b00aa4f35 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 @@ -28,8 +28,12 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.squareup.workflow1.ui.AsScreen +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ViewFactory import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.composeScreenViewFactory import com.squareup.workflow1.ui.compose.composeViewFactory /** @@ -65,6 +69,40 @@ internal fun placeholderViewFactory(modifier: Modifier): ViewFactory = } } +/** + * A [ScreenViewFactory] that will be used any time a [PreviewScreenViewFactoryFinder] + * 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, _ -> + BoxWithConstraints { + BasicText( + modifier = modifier + .clipToBounds() + .drawBehind { + drawIntoCanvas { canvas -> + canvas.withSaveLayer(size.toRect(), Paint().apply { alpha = .2f }) { + canvas.drawRect(size.toRect(), Paint().apply { color = Color.Gray }) + drawCrossHatch( + color = Color.Red, + strokeWidth = 2.dp, + spaceWidth = 8.dp, + ) + } + } + } + .padding(8.dp), + text = (rendering as? AsScreen<*>)?.rendering?.toString() ?: rendering.toString(), + style = TextStyle( + textAlign = TextAlign.Center, + color = Color.White, + shadow = Shadow(blurRadius = 5f, color = Color.Black) + ) + ) + } + } + @Preview(widthDp = 200, heightDp = 200) @Composable private fun PreviewStubViewBindingOnWhite() { Box(Modifier.background(Color.White)) { @@ -95,13 +133,27 @@ internal fun placeholderViewFactory(modifier: Modifier): ViewFactory = @Composable private fun PreviewStubBindingPreviewTemplate(previewRendering: String = "preview") { placeholderViewFactory(Modifier).Preview( - rendering = previewRendering, + rendering = PlaceholderRendering(previewRendering), placeholderModifier = Modifier .fillMaxSize() .border(width = 1.dp, color = Color.Red) ) } +private class PlaceholderRendering(val text: String = "preview") : Screen { + override fun equals(other: Any?): Boolean { + return (other as? PlaceholderRendering)?.text == text + } + + override fun hashCode(): Int { + return text.hashCode() + } + + override fun toString(): String { + return text + } +} + private fun DrawScope.drawCrossHatch( color: Color, strokeWidth: Dp, @@ -146,6 +198,3 @@ private fun DrawScope.drawHatch( ) } } - -private fun Color.scaleColors(factor: Float) = - copy(red = red * factor, green = green * factor, blue = blue * factor) 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 f7a6edfd86..a53ce0f6e9 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 @@ -7,6 +7,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember 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.ViewFactory import com.squareup.workflow1.ui.ViewRegistry @@ -14,14 +17,7 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.plus import kotlin.reflect.KClass -/** - * Creates and [remember]s a [ViewEnvironment] that has a special [ViewRegistry] and any additional - * elements as configured by [viewEnvironmentUpdater]. - * - * The [ViewRegistry] will contain [mainFactory] if specified, as well as a [placeholderViewFactory] - * that will be used to show any renderings that don't match [mainFactory]'s type. All placeholders - * will have [placeholderModifier] applied. - */ +@Deprecated("Use overload with a ScreenViewFactory parameter.") @Composable internal fun previewViewEnvironment( placeholderModifier: Modifier, viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null, @@ -38,9 +34,33 @@ import kotlin.reflect.KClass } } +/** + * 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 + * [placeholderViewFactory] that will be used to show any renderings that don't match + * [mainFactory]'s type. All placeholders will have [placeholderModifier] applied. + */ +@Composable internal fun previewViewEnvironment( + placeholderModifier: Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null, + mainFactory: ScreenViewFactory<*>? = null +): ViewEnvironment { + val finder = remember(mainFactory, placeholderModifier) { + PreviewScreenViewFactoryFinder(mainFactory, placeholderScreenViewFactory(placeholderModifier)) + } + return remember(finder, viewEnvironmentUpdater) { + (ViewEnvironment.EMPTY + (ScreenViewFactoryFinder to finder)).let { environment -> + // Give the preview a chance to add its own elements to the ViewEnvironment. + viewEnvironmentUpdater?.let { it(environment) } ?: environment + } + } +} + /** * A [ViewRegistry] that uses [mainFactory] for rendering [RenderingT]s, and [placeholderFactory] - * for all other [WorkflowRendering][com.squareup.workflow.ui.compose.WorkflowRendering] calls. + * for all other [WorkflowRendering][com.squareup.workflow1.ui.compose.WorkflowRendering] calls. */ @Immutable private class PreviewViewRegistry( @@ -57,3 +77,22 @@ private class PreviewViewRegistry( else -> placeholderFactory } as ViewFactory } + +/** + * A [ScreenViewFactoryFinder] that uses [mainFactory] for rendering [RenderingT]s, + * and [placeholderFactory] for all other + * [WorkflowRendering][com.squareup.workflow1.ui.compose.WorkflowRendering] calls. + */ +@Immutable +private class PreviewScreenViewFactoryFinder( + private val mainFactory: ScreenViewFactory? = null, + private val placeholderFactory: ScreenViewFactory +) : ScreenViewFactoryFinder { + override fun getViewFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenViewFactory = + @Suppress("UNCHECKED_CAST") + if (rendering::class == mainFactory?.type) mainFactory as ScreenViewFactory + else placeholderFactory +} 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 index 42c5fd3cbb..f9ad2b9fa8 100644 --- 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 @@ -1,4 +1,3 @@ -@file:Suppress("DEPRECATION", "FunctionName") package com.squareup.workflow1.ui.compose.tooling import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy @@ -10,30 +9,31 @@ 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.ViewFactory -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.composeViewFactory +import com.squareup.workflow1.ui.compose.composeScreenViewFactory /** - * Draws this [ViewFactory] using a special preview [ViewRegistry]. + * Draws this [ScreenViewFactory] using a special preview [ScreenViewFactoryFinder]. * * Use inside `@Preview` Composable functions. * - * *Note: [rendering] must be the same type as this [ViewFactory], even though the type system does - * not enforce this constraint. This is due to a Compose compiler bug tracked + * *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 [ViewFactory]. + * @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 ViewFactory.Preview( +@Composable public fun ScreenViewFactory.Preview( rendering: RenderingT, modifier: Modifier = Modifier, placeholderModifier: Modifier = Modifier, @@ -47,15 +47,16 @@ import com.squareup.workflow1.ui.compose.composeViewFactory @OptIn(WorkflowUiExperimentalApi::class) @Preview(showBackground = true) @Composable private fun ViewFactoryPreviewPreview() { - val factory = composeViewFactory { _, environment -> + val factory = composeScreenViewFactory { _, environment -> Column( verticalArrangement = spacedBy(8.dp), modifier = Modifier.padding(8.dp) ) { BasicText("Top text") WorkflowRendering( - rendering = "Child rendering with very long text to suss out cross-hatch rendering " + - "edge cases", + rendering = TextRendering( + "Child rendering with very long text to suss out cross-hatch rendering edge cases", + ), viewEnvironment = environment, modifier = Modifier .aspectRatio(1f) @@ -65,5 +66,8 @@ import com.squareup.workflow1.ui.compose.composeViewFactory } } - factory.Preview(Unit) + factory.Preview(object : Screen {}) } + +@OptIn(WorkflowUiExperimentalApi::class) +private data class TextRendering(val text: String) : Screen diff --git a/workflow-ui/compose/api/compose.api b/workflow-ui/compose/api/compose.api index 12c4480f41..47af2e7c4f 100644 --- a/workflow-ui/compose/api/compose.api +++ b/workflow-ui/compose/api/compose.api @@ -12,6 +12,32 @@ public final class com/squareup/workflow1/ui/compose/ComposeRenderingKt { public static final fun ComposeRendering (Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/compose/ComposeRendering; } +public abstract interface class com/squareup/workflow1/ui/compose/ComposeScreen : com/squareup/workflow1/ui/AndroidScreen { + public static final synthetic field Companion Lcom/squareup/workflow1/ui/compose/ComposeScreen$Companion; + 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/ComposeScreen$DefaultImpls { + public static fun getViewFactory (Lcom/squareup/workflow1/ui/compose/ComposeScreen;)Lcom/squareup/workflow1/ui/ScreenViewFactory; +} + +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 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;)Landroid/view/View; +} + +public final class com/squareup/workflow1/ui/compose/ComposeScreenViewFactoryKt { + public static final synthetic fun composeScreenViewFactory (Lkotlin/jvm/functions/Function4;)Lcom/squareup/workflow1/ui/ScreenViewFactory; + public static final fun composeScreenViewFactory (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function4;)Lcom/squareup/workflow1/ui/ScreenViewFactory; +} + public abstract class com/squareup/workflow1/ui/compose/ComposeViewFactory : com/squareup/workflow1/ui/ViewFactory { public static final field $stable I public fun ()V @@ -26,10 +52,15 @@ public final class com/squareup/workflow1/ui/compose/ComposeViewFactoryKt { public final class com/squareup/workflow1/ui/compose/CompositionRootKt { public static final fun WrappedWithRootIfNecessary (Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + 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 static final fun withCompositionRoot (Lcom/squareup/workflow1/ui/ViewRegistry;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/ViewRegistry; } +public final class com/squareup/workflow1/ui/compose/LegacyWorkflowRenderingKt { + public static final fun WorkflowRendering (Ljava/lang/Object;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + 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;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; public static final fun renderAsState (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Ljava/util/List;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; @@ -40,6 +71,6 @@ public final class com/squareup/workflow1/ui/compose/TextControllerAsMutableStat } public final class com/squareup/workflow1/ui/compose/WorkflowRenderingKt { - public static final fun WorkflowRendering (Ljava/lang/Object;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun WorkflowRendering (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewFactoryTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewFactoryTest.kt index ff000b3b78..2bd705d240 100644 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewFactoryTest.kt +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewFactoryTest.kt @@ -1,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package com.squareup.workflow1.ui.compose import android.content.Context @@ -18,6 +16,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.viewinterop.AndroidView import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewEnvironmentKey import com.squareup.workflow1.ui.ViewRegistry @@ -44,14 +43,14 @@ internal class ComposeViewFactoryTest { .around(IdlingDispatcherRule) @Test fun showsComposeContent() { - val viewFactory = composeViewFactory { _, _ -> + val viewFactory = composeScreenViewFactory { _, _ -> BasicText("Hello, world!") } val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(viewFactory) composeRule.setContent { AndroidView(::RootView) { - it.stub.update(Unit, viewEnvironment) + it.stub.show(TestRendering(), viewEnvironment) } } @@ -59,20 +58,20 @@ internal class ComposeViewFactoryTest { } @Test fun getsRenderingUpdates() { - val viewFactory = composeViewFactory { rendering, _ -> - BasicText(rendering, Modifier.testTag("text")) + val viewFactory = composeScreenViewFactory { rendering, _ -> + BasicText(rendering.text, Modifier.testTag("text")) } val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(viewFactory) - var rendering by mutableStateOf("hello") + var rendering by mutableStateOf(TestRendering("hello")) composeRule.setContent { AndroidView(::RootView) { - it.stub.update(rendering, viewEnvironment) + it.stub.show(rendering, viewEnvironment) } } composeRule.onNodeWithTag("text").assertTextEquals("hello") - rendering = "world" + rendering = TestRendering("world") composeRule.onNodeWithTag("text").assertTextEquals("world") } @@ -82,7 +81,7 @@ internal class ComposeViewFactoryTest { override val default: String get() = error("No default") } - val viewFactory = composeViewFactory { _, environment -> + val viewFactory = composeScreenViewFactory { _, environment -> val text = environment[testEnvironmentKey] BasicText(text, Modifier.testTag("text")) } @@ -93,7 +92,7 @@ internal class ComposeViewFactoryTest { composeRule.setContent { AndroidView(::RootView) { - it.stub.update(Unit, viewEnvironment) + it.stub.show(TestRendering(), viewEnvironment) } } composeRule.onNodeWithTag("text").assertTextEquals("hello") @@ -105,7 +104,7 @@ internal class ComposeViewFactoryTest { @Test fun wrapsFactoryWithRoot() { val wrapperText = mutableStateOf("one") - val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(TestFactory) + val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(TestFactory)) .withCompositionRoot { content -> Column { BasicText(wrapperText.value) @@ -115,7 +114,7 @@ internal class ComposeViewFactoryTest { composeRule.setContent { AndroidView(::RootView) { - it.stub.update(TestRendering("two"), viewEnvironment) + it.stub.show(TestRendering("two"), viewEnvironment) } } @@ -134,10 +133,10 @@ internal class ComposeViewFactoryTest { val stub = WorkflowViewStub(context).also(::addView) } - private data class TestRendering(val text: String) + private data class TestRendering(val text: String = "") : Screen private companion object { - val TestFactory = composeViewFactory { rendering, _ -> + val TestFactory = composeScreenViewFactory { rendering, _ -> BasicText(rendering.text) } } diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt index a4c1b3c0b1..2654e0e21e 100644 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt @@ -1,7 +1,6 @@ -@file:Suppress("DEPRECATION") - package com.squareup.workflow1.ui.compose +import android.app.Dialog import android.content.Context import android.view.View import android.view.ViewGroup @@ -26,21 +25,24 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.google.common.truth.Truth.assertThat -import com.squareup.workflow1.ui.AndroidViewRendering +import com.squareup.workflow1.ui.AndroidScreen import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.asScreen import com.squareup.workflow1.ui.bindShowRendering +import com.squareup.workflow1.ui.container.AndroidOverlay import com.squareup.workflow1.ui.container.BackStackScreen +import com.squareup.workflow1.ui.container.BodyAndModalsScreen +import com.squareup.workflow1.ui.container.ModalScreenOverlayDialogFactory +import com.squareup.workflow1.ui.container.Overlay +import com.squareup.workflow1.ui.container.ScreenOverlay import com.squareup.workflow1.ui.internal.test.DetectLeaksAfterTestSuccess import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule import com.squareup.workflow1.ui.internal.test.WorkflowUiTestActivity -import com.squareup.workflow1.ui.modal.HasModals -import com.squareup.workflow1.ui.modal.ModalViewContainer import com.squareup.workflow1.ui.plus import org.junit.Before import org.junit.Rule @@ -61,16 +63,15 @@ internal class ComposeViewTreeIntegrationTest { @Before fun setUp() { scenario.onActivity { - it.viewEnvironment = ViewEnvironment.EMPTY + - ViewRegistry(ModalViewContainer.binding(), NoTransitionBackStackContainer) + it.viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(NoTransitionBackStackContainer) } } @Test fun compose_view_assertions_work() { - val firstScreen = ComposeRendering("first") { + val firstScreen = TestComposeRendering("first") { BasicText("First Screen") } - val secondScreen = ComposeRendering("second") {} + val secondScreen = TestComposeRendering("second") {} scenario.onActivity { it.setBackstack(firstScreen) @@ -89,7 +90,7 @@ internal class ComposeViewTreeIntegrationTest { @Test fun composition_is_disposed_when_navigated_away_dispose_on_detach_strategy() { var composedCount = 0 var disposedCount = 0 - val firstScreen = ComposeRendering("first", disposeStrategy = DisposeOnDetachedFromWindow) { + val firstScreen = TestComposeRendering("first", disposeStrategy = DisposeOnDetachedFromWindow) { DisposableEffect(Unit) { composedCount++ onDispose { @@ -97,7 +98,7 @@ internal class ComposeViewTreeIntegrationTest { } } } - val secondScreen = ComposeRendering("second") {} + val secondScreen = TestComposeRendering("second") {} scenario.onActivity { it.setBackstack(firstScreen) @@ -123,7 +124,7 @@ internal class ComposeViewTreeIntegrationTest { var composedCount = 0 var disposedCount = 0 val firstScreen = - ComposeRendering("first", disposeStrategy = DisposeOnViewTreeLifecycleDestroyed) { + TestComposeRendering("first", disposeStrategy = DisposeOnViewTreeLifecycleDestroyed) { DisposableEffect(Unit) { composedCount++ onDispose { @@ -131,7 +132,7 @@ internal class ComposeViewTreeIntegrationTest { } } } - val secondScreen = ComposeRendering("second") {} + val secondScreen = TestComposeRendering("second") {} scenario.onActivity { it.setBackstack(firstScreen) @@ -154,7 +155,7 @@ internal class ComposeViewTreeIntegrationTest { } @Test fun composition_state_is_restored_after_config_change() { - val firstScreen = ComposeRendering("first") { + val firstScreen = TestComposeRendering("first") { var counter by rememberSaveable { mutableStateOf(0) } BasicText( "Counter: $counter", @@ -181,7 +182,7 @@ internal class ComposeViewTreeIntegrationTest { } @Test fun composition_state_is_restored_after_navigating_back() { - val firstScreen = ComposeRendering("first") { + val firstScreen = TestComposeRendering("first") { var counter by rememberSaveable { mutableStateOf(0) } BasicText( "Counter: $counter", @@ -190,7 +191,7 @@ internal class ComposeViewTreeIntegrationTest { .testTag(CounterTag) ) } - val secondScreen = ComposeRendering("second") { + val secondScreen = TestComposeRendering("second") { BasicText("nothing to see here") } @@ -222,7 +223,7 @@ internal class ComposeViewTreeIntegrationTest { @Test fun composition_state_is_restored_after_config_change_then_navigating_back() { - val firstScreen = ComposeRendering("first") { + val firstScreen = TestComposeRendering("first") { var counter by rememberSaveable { mutableStateOf(0) } BasicText( "Counter: $counter", @@ -231,7 +232,7 @@ internal class ComposeViewTreeIntegrationTest { .testTag(CounterTag) ) } - val secondScreen = ComposeRendering("second") { + val secondScreen = TestComposeRendering("second") { BasicText("nothing to see here") } @@ -264,7 +265,7 @@ internal class ComposeViewTreeIntegrationTest { } @Test fun composition_state_is_not_restored_after_screen_is_removed_from_backstack() { - val firstScreen = ComposeRendering("first") { + val firstScreen = TestComposeRendering("first") { var counter by rememberSaveable { mutableStateOf(0) } BasicText( "Counter: $counter", @@ -273,7 +274,7 @@ internal class ComposeViewTreeIntegrationTest { .testTag(CounterTag) ) } - val secondScreen = ComposeRendering("second") { + val secondScreen = TestComposeRendering("second") { BasicText("nothing to see here") } @@ -309,7 +310,7 @@ internal class ComposeViewTreeIntegrationTest { @Test fun composition_state_is_not_restored_after_screen_is_removed_and_replaced_from_backstack() { - val firstScreen = ComposeRendering("first") { + val firstScreen = TestComposeRendering("first") { var counter by rememberSaveable { mutableStateOf(0) } BasicText( "Counter: $counter", @@ -318,7 +319,7 @@ internal class ComposeViewTreeIntegrationTest { .testTag(CounterTag) ) } - val secondScreen = ComposeRendering("second") { + val secondScreen = TestComposeRendering("second") { BasicText("nothing to see here") } @@ -358,24 +359,20 @@ internal class ComposeViewTreeIntegrationTest { } @Test fun composition_is_restored_in_modal_after_config_change() { - val firstScreen = asScreen( - ComposeRendering(compatibilityKey = "") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag) - ) - } - ) + val firstScreen: Screen = TestComposeRendering(compatibilityKey = "") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } // Show first screen to initialize state. scenario.onActivity { it.setRendering( - asScreen( - TestModalScreen(listOf(BackStackScreen(EmptyRendering, firstScreen))) - ) + BodyAndModalsScreen(BackStackScreen(EmptyRendering, firstScreen)) ) } @@ -391,55 +388,45 @@ internal class ComposeViewTreeIntegrationTest { } @Test fun composition_is_restored_in_multiple_modals_after_config_change() { - val firstScreen = asScreen( - ComposeRendering(compatibilityKey = "key") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag) - ) - } - ) + val firstScreen: Screen = TestComposeRendering(compatibilityKey = "key") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + // Use the same compatibility key – these screens are in different modals, so they won't // conflict. - val secondScreen = asScreen( - ComposeRendering(compatibilityKey = "key") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter2: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag2) - ) - } - ) + val secondScreen: Screen = TestComposeRendering(compatibilityKey = "key") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter2: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag2) + ) + } + // Use the same compatibility key – these screens are in different modals, so they won't // conflict. - val thirdScreen = asScreen( - ComposeRendering(compatibilityKey = "key") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter3: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag3) - ) - } - ) + val thirdScreen: Screen = TestComposeRendering(compatibilityKey = "key") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter3: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag3) + ) + } // Show first screen to initialize state. scenario.onActivity { it.setRendering( - asScreen( - TestModalScreen( - listOf( - firstScreen, - secondScreen, - thirdScreen - ) - ) + BodyAndModalsScreen( + EmptyRendering, TestModal(firstScreen), TestModal(secondScreen), TestModal(thirdScreen) ) ) } @@ -475,21 +462,19 @@ internal class ComposeViewTreeIntegrationTest { fun createRendering( layer: Int, screen: Int - ) = asScreen( - ComposeRendering( - // Use the same compatibility key across layers – these screens are in different modals, so - // they won't conflict. - compatibilityKey = screen.toString() - ) { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter[$layer][$screen]: $counter", - Modifier - .clickable { counter++ } - .testTag("L${layer}S$screen") - ) - } - ) + ) = TestComposeRendering( + // Use the same compatibility key across layers – these screens are in different modals, so + // they won't conflict. + compatibilityKey = screen.toString() + ) { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter[$layer][$screen]: $counter", + Modifier + .clickable { counter++ } + .testTag("L${layer}S$screen") + ) + } val layer0Screen0 = createRendering(0, 0) val layer0Screen1 = createRendering(0, 1) @@ -499,13 +484,10 @@ internal class ComposeViewTreeIntegrationTest { // Show first screen to initialize state. scenario.onActivity { it.setRendering( - asScreen( - TestModalScreen( - listOf( - BackStackScreen(EmptyRendering, layer0Screen0), - BackStackScreen(EmptyRendering, layer1Screen0) - ) - ) + BodyAndModalsScreen( + EmptyRendering, + TestModal(BackStackScreen(EmptyRendering, layer0Screen0)), + TestModal(BackStackScreen(EmptyRendering, layer1Screen0)), ) ) } @@ -525,13 +507,10 @@ internal class ComposeViewTreeIntegrationTest { // Push some screens onto the backstack. scenario.onActivity { it.setRendering( - asScreen( - TestModalScreen( - listOf( - BackStackScreen(EmptyRendering, layer0Screen0, layer0Screen1), - BackStackScreen(EmptyRendering, layer1Screen0, layer1Screen1) - ) - ) + BodyAndModalsScreen( + EmptyRendering, + TestModal(BackStackScreen(EmptyRendering, layer0Screen0, layer0Screen1)), + TestModal(BackStackScreen(EmptyRendering, layer1Screen0, layer1Screen1)), ) ) } @@ -565,13 +544,10 @@ internal class ComposeViewTreeIntegrationTest { // Pop both backstacks and check that screens were restored. scenario.onActivity { it.setRendering( - asScreen( - TestModalScreen( - listOf( - BackStackScreen(EmptyRendering, layer0Screen0), - BackStackScreen(EmptyRendering, layer1Screen0) - ) - ) + BodyAndModalsScreen( + EmptyRendering, + TestModal(BackStackScreen(EmptyRendering, layer0Screen0)), + TestModal(BackStackScreen(EmptyRendering, layer1Screen0)), ) ) } @@ -582,26 +558,32 @@ internal class ComposeViewTreeIntegrationTest { .assertIsDisplayed() } - private fun WorkflowUiTestActivity.setBackstack(vararg backstack: ComposeRendering) { - setRendering(BackStackScreen(EmptyRendering, backstack.asList().map { asScreen(it) })) + private fun WorkflowUiTestActivity.setBackstack(vararg backstack: TestComposeRendering) { + setRendering(BackStackScreen(EmptyRendering, backstack.asList())) } - data class TestModalScreen( - override val modals: List = emptyList() - ) : HasModals { - override val beneathModals = EmptyRendering + data class TestModal( + override val content: Screen + ) : ScreenOverlay, AndroidOverlay { + override val dialogFactory = object : ModalScreenOverlayDialogFactory( + TestModal::class + ) { + override fun buildDialogWithContentView(contentView: View): Dialog { + return Dialog(contentView.context).apply { setContentView(contentView) } + } + } } - data class ComposeRendering( + data class TestComposeRendering( override val compatibilityKey: String, val disposeStrategy: ViewCompositionStrategy? = null, val content: @Composable () -> Unit - ) : Compatible, AndroidViewRendering, ViewFactory { - override val type: KClass = ComposeRendering::class - override val viewFactory: ViewFactory get() = this + ) : Compatible, AndroidScreen, ScreenViewFactory { + override val type: KClass = TestComposeRendering::class + override val viewFactory: ScreenViewFactory get() = this override fun buildView( - initialRendering: ComposeRendering, + initialRendering: TestComposeRendering, initialViewEnvironment: ViewEnvironment, contextForNewView: Context, container: ViewGroup? @@ -626,7 +608,7 @@ internal class ComposeViewTreeIntegrationTest { companion object { // Use a ComposeView here because the Compose test infra doesn't like it if there are no // Compose views at all. See https://issuetracker.google.com/issues/179455327. - val EmptyRendering = asScreen(ComposeRendering(compatibilityKey = "") {}) + val EmptyRendering: Screen = TestComposeRendering(compatibilityKey = "") {} const val CounterTag = "counter" const val CounterTag2 = "counter2" diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyComposeViewFactoryTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyComposeViewFactoryTest.kt new file mode 100644 index 0000000000..3e0452b159 --- /dev/null +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyComposeViewFactoryTest.kt @@ -0,0 +1,144 @@ +@file:Suppress("DEPRECATION") + +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.viewinterop.AndroidView +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.WorkflowViewStub +import com.squareup.workflow1.ui.internal.test.DetectLeaksAfterTestSuccess +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.ui.plus +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +@OptIn(WorkflowUiExperimentalApi::class) +@RunWith(AndroidJUnit4::class) +internal class LegacyComposeViewFactoryTest { + + private val composeRule = createComposeRule() + @get:Rule val rules: RuleChain = + RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + @Test fun showsComposeContent() { + val viewFactory = composeViewFactory { _, _ -> + BasicText("Hello, world!") + } + val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(viewFactory) + + composeRule.setContent { + AndroidView(::RootView) { + it.stub.update(Unit, viewEnvironment) + } + } + + composeRule.onNodeWithText("Hello, world!").assertIsDisplayed() + } + + @Test fun getsRenderingUpdates() { + val viewFactory = composeViewFactory { rendering, _ -> + BasicText(rendering, Modifier.testTag("text")) + } + val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(viewFactory) + var rendering by mutableStateOf("hello") + + composeRule.setContent { + AndroidView(::RootView) { + it.stub.update(rendering, viewEnvironment) + } + } + composeRule.onNodeWithTag("text").assertTextEquals("hello") + + rendering = "world" + + composeRule.onNodeWithTag("text").assertTextEquals("world") + } + + @Test fun getsViewEnvironmentUpdates() { + val testEnvironmentKey = object : ViewEnvironmentKey(String::class) { + override val default: String get() = error("No default") + } + + val viewFactory = composeViewFactory { _, environment -> + val text = environment[testEnvironmentKey] + BasicText(text, Modifier.testTag("text")) + } + val viewRegistry = ViewRegistry(viewFactory) + var viewEnvironment by mutableStateOf( + ViewEnvironment.EMPTY + viewRegistry + (testEnvironmentKey to "hello") + ) + + composeRule.setContent { + AndroidView(::RootView) { + it.stub.update(Unit, viewEnvironment) + } + } + composeRule.onNodeWithTag("text").assertTextEquals("hello") + + viewEnvironment = viewEnvironment + (testEnvironmentKey to "world") + + composeRule.onNodeWithTag("text").assertTextEquals("world") + } + + @Test fun wrapsFactoryWithRoot() { + val wrapperText = mutableStateOf("one") + val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(TestFactory) + .withCompositionRoot { content -> + Column { + BasicText(wrapperText.value) + content() + } + } + + composeRule.setContent { + AndroidView(::RootView) { + it.stub.update(TestRendering("two"), viewEnvironment) + } + } + + // Compose bug doesn't let us use assertIsDisplayed on older devices. + // See https://issuetracker.google.com/issues/157728188. + composeRule.onNodeWithText("one").assertExists() + composeRule.onNodeWithText("two").assertExists() + + wrapperText.value = "ENO" + + composeRule.onNodeWithText("ENO").assertExists() + composeRule.onNodeWithText("two").assertExists() + } + + private class RootView(context: Context) : FrameLayout(context) { + val stub = WorkflowViewStub(context).also(::addView) + } + + private data class TestRendering(val text: String) + + private companion object { + val TestFactory = composeViewFactory { rendering, _ -> + BasicText(rendering.text) + } + } +} diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyComposeViewTreeIntegrationTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyComposeViewTreeIntegrationTest.kt new file mode 100644 index 0000000000..9969ea80b5 --- /dev/null +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyComposeViewTreeIntegrationTest.kt @@ -0,0 +1,635 @@ +@file:Suppress("DEPRECATION") + +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnDetachedFromWindow +import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.AndroidViewRendering +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewFactory +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.asScreen +import com.squareup.workflow1.ui.bindShowRendering +import com.squareup.workflow1.ui.container.BackStackScreen +import com.squareup.workflow1.ui.internal.test.DetectLeaksAfterTestSuccess +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.ui.internal.test.WorkflowUiTestActivity +import com.squareup.workflow1.ui.modal.HasModals +import com.squareup.workflow1.ui.modal.ModalViewContainer +import com.squareup.workflow1.ui.plus +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import kotlin.reflect.KClass + +@OptIn(WorkflowUiExperimentalApi::class) +internal class LegacyComposeViewTreeIntegrationTest { + + private val composeRule = createAndroidComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + private val scenario get() = composeRule.activityRule.scenario + + @Before fun setUp() { + scenario.onActivity { + it.viewEnvironment = ViewEnvironment.EMPTY + + ViewRegistry(ModalViewContainer.binding(), NoTransitionBackStackContainer) + } + } + + @Test fun compose_view_assertions_work() { + val firstScreen = TestComposeRendering("first") { + BasicText("First Screen") + } + val secondScreen = TestComposeRendering("second") {} + + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithText("First Screen").assertIsDisplayed() + + // Navigate away from the first screen. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.onNodeWithText("First Screen").assertDoesNotExist() + } + + @Test fun composition_is_disposed_when_navigated_away_dispose_on_detach_strategy() { + var composedCount = 0 + var disposedCount = 0 + val firstScreen = TestComposeRendering("first", disposeStrategy = DisposeOnDetachedFromWindow) { + DisposableEffect(Unit) { + composedCount++ + onDispose { + disposedCount++ + } + } + } + val secondScreen = TestComposeRendering("second") {} + + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(0) + } + + // Navigate away. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(1) + } + } + + @Test fun composition_is_disposed_when_navigated_away_dispose_on_destroy_strategy() { + var composedCount = 0 + var disposedCount = 0 + val firstScreen = + TestComposeRendering("first", disposeStrategy = DisposeOnViewTreeLifecycleDestroyed) { + DisposableEffect(Unit) { + composedCount++ + onDispose { + disposedCount++ + } + } + } + val secondScreen = TestComposeRendering("second") {} + + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(0) + } + + // Navigate away. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(1) + } + } + + @Test fun composition_state_is_restored_after_config_change() { + val firstScreen = TestComposeRendering("first") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + .assertTextEquals("Counter: 1") + + // Simulate config change. + scenario.recreate() + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + } + + @Test fun composition_state_is_restored_after_navigating_back() { + val firstScreen = TestComposeRendering("first") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + val secondScreen = TestComposeRendering("second") { + BasicText("nothing to see here") + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + .assertTextEquals("Counter: 1") + + // Add a screen to the backstack. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.onNodeWithTag(CounterTag) + .assertDoesNotExist() + + // Navigate back. + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + } + + @Test + fun composition_state_is_restored_after_config_change_then_navigating_back() { + val firstScreen = TestComposeRendering("first") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + val secondScreen = TestComposeRendering("second") { + BasicText("nothing to see here") + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + .assertTextEquals("Counter: 1") + + // Add a screen to the backstack. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + scenario.recreate() + + composeRule.onNodeWithText("nothing to see here") + .assertIsDisplayed() + + // Navigate to the first screen again. + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + } + + @Test fun composition_state_is_not_restored_after_screen_is_removed_from_backstack() { + val firstScreen = TestComposeRendering("first") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + val secondScreen = TestComposeRendering("second") { + BasicText("nothing to see here") + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + + // Add a screen to the backstack. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + // Remove the initial screen from the backstack – this should drop its state. + scenario.onActivity { + it.setBackstack(secondScreen) + } + + // Navigate to the first screen again. + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + } + + @Test + fun composition_state_is_not_restored_after_screen_is_removed_and_replaced_from_backstack() { + val firstScreen = TestComposeRendering("first") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + val secondScreen = TestComposeRendering("second") { + BasicText("nothing to see here") + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + + // Add a screen to the backstack. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + // Remove the initial screen from the backstack – this should drop its state. + scenario.onActivity { + it.setBackstack(secondScreen) + } + + // Put the initial screen back – it should still not have saved state anymore. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + // Navigate to the first screen again. + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + } + + @Test fun composition_is_restored_in_modal_after_config_change() { + val firstScreen = asScreen( + TestComposeRendering(compatibilityKey = "") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + ) + + // Show first screen to initialize state. + scenario.onActivity { + it.setRendering( + asScreen( + TestModalScreen(listOf(BackStackScreen(EmptyRendering, firstScreen))) + ) + ) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + .assertTextEquals("Counter: 1") + + scenario.recreate() + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + } + + @Test fun composition_is_restored_in_multiple_modals_after_config_change() { + val firstScreen = asScreen( + TestComposeRendering(compatibilityKey = "key") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + ) + // Use the same compatibility key – these screens are in different modals, so they won't + // conflict. + val secondScreen = asScreen( + TestComposeRendering(compatibilityKey = "key") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter2: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag2) + ) + } + ) + // Use the same compatibility key – these screens are in different modals, so they won't + // conflict. + val thirdScreen = asScreen( + TestComposeRendering(compatibilityKey = "key") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter3: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag3) + ) + } + ) + + // Show first screen to initialize state. + scenario.onActivity { + it.setRendering( + asScreen( + TestModalScreen( + listOf( + firstScreen, + secondScreen, + thirdScreen + ) + ) + ) + ) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + .assertTextEquals("Counter: 1") + + composeRule.onNodeWithTag(CounterTag2) + .assertTextEquals("Counter2: 0") + .performClick() + .assertTextEquals("Counter2: 1") + + composeRule.onNodeWithTag(CounterTag3) + .assertTextEquals("Counter3: 0") + .performClick() + .assertTextEquals("Counter3: 1") + + scenario.recreate() + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + + composeRule.onNodeWithTag(CounterTag2) + .assertTextEquals("Counter2: 1") + + composeRule.onNodeWithTag(CounterTag3) + .assertTextEquals("Counter3: 1") + } + + @Test fun composition_is_restored_in_multiple_modals_backstacks_after_config_change() { + fun createRendering( + layer: Int, + screen: Int + ) = asScreen( + TestComposeRendering( + // Use the same compatibility key across layers – these screens are in different modals, so + // they won't conflict. + compatibilityKey = screen.toString() + ) { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter[$layer][$screen]: $counter", + Modifier + .clickable { counter++ } + .testTag("L${layer}S$screen") + ) + } + ) + + val layer0Screen0 = createRendering(0, 0) + val layer0Screen1 = createRendering(0, 1) + val layer1Screen0 = createRendering(1, 0) + val layer1Screen1 = createRendering(1, 1) + + // Show first screen to initialize state. + scenario.onActivity { + it.setRendering( + asScreen( + TestModalScreen( + listOf( + BackStackScreen(EmptyRendering, layer0Screen0), + BackStackScreen(EmptyRendering, layer1Screen0) + ) + ) + ) + ) + } + + composeRule.onNodeWithTag("L0S0") + .assertTextEquals("Counter[0][0]: 0") + .assertIsDisplayed() + .performClick() + .assertTextEquals("Counter[0][0]: 1") + + composeRule.onNodeWithTag("L1S0") + .assertTextEquals("Counter[1][0]: 0") + .assertIsDisplayed() + .performClick() + .assertTextEquals("Counter[1][0]: 1") + + // Push some screens onto the backstack. + scenario.onActivity { + it.setRendering( + asScreen( + TestModalScreen( + listOf( + BackStackScreen(EmptyRendering, layer0Screen0, layer0Screen1), + BackStackScreen(EmptyRendering, layer1Screen0, layer1Screen1) + ) + ) + ) + ) + } + + composeRule.onNodeWithTag("L0S0") + .assertDoesNotExist() + composeRule.onNodeWithTag("L1S0") + .assertDoesNotExist() + + composeRule.onNodeWithTag("L0S1") + .assertTextEquals("Counter[0][1]: 0") + .assertIsDisplayed() + .performClick() + .assertTextEquals("Counter[0][1]: 1") + + composeRule.onNodeWithTag("L1S1") + .assertTextEquals("Counter[1][1]: 0") + .assertIsDisplayed() + .performClick() + .assertTextEquals("Counter[1][1]: 1") + + // Simulate config change. + scenario.recreate() + + // Check that the last-shown screens were restored. + composeRule.onNodeWithTag("L0S1") + .assertIsDisplayed() + composeRule.onNodeWithTag("L1S1") + .assertIsDisplayed() + + // Pop both backstacks and check that screens were restored. + scenario.onActivity { + it.setRendering( + asScreen( + TestModalScreen( + listOf( + BackStackScreen(EmptyRendering, layer0Screen0), + BackStackScreen(EmptyRendering, layer1Screen0) + ) + ) + ) + ) + } + + composeRule.onNodeWithText("Counter[0][0]: 1") + .assertIsDisplayed() + composeRule.onNodeWithText("Counter[1][0]: 1") + .assertIsDisplayed() + } + + private fun WorkflowUiTestActivity.setBackstack(vararg backstack: TestComposeRendering) { + setRendering(BackStackScreen(EmptyRendering, backstack.asList().map { asScreen(it) })) + } + + data class TestModalScreen( + override val modals: List = emptyList() + ) : HasModals { + override val beneathModals = EmptyRendering + } + + data class TestComposeRendering( + override val compatibilityKey: String, + val disposeStrategy: ViewCompositionStrategy? = null, + val content: @Composable () -> Unit + ) : Compatible, AndroidViewRendering, ViewFactory { + override val type: KClass = TestComposeRendering::class + override val viewFactory: ViewFactory get() = this + + override fun buildView( + initialRendering: TestComposeRendering, + initialViewEnvironment: ViewEnvironment, + contextForNewView: Context, + container: ViewGroup? + ): View { + var lastCompositionStrategy = initialRendering.disposeStrategy + + return ComposeView(contextForNewView).apply { + lastCompositionStrategy?.let(::setViewCompositionStrategy) + + bindShowRendering(initialRendering, initialViewEnvironment) { rendering, _ -> + if (rendering.disposeStrategy != lastCompositionStrategy) { + lastCompositionStrategy = rendering.disposeStrategy + lastCompositionStrategy?.let(::setViewCompositionStrategy) + } + + setContent(rendering.content) + } + } + } + } + + companion object { + // Use a ComposeView here because the Compose test infra doesn't like it if there are no + // Compose views at all. See https://issuetracker.google.com/issues/179455327. + val EmptyRendering = asScreen(TestComposeRendering(compatibilityKey = "") {}) + + const val CounterTag = "counter" + const val CounterTag2 = "counter2" + const val CounterTag3 = "counter3" + } +} diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyWorkflowRenderingTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyWorkflowRenderingTest.kt new file mode 100644 index 0000000000..50b80b4567 --- /dev/null +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/LegacyWorkflowRenderingTest.kt @@ -0,0 +1,580 @@ +@file:Suppress("TestFunctionName", "DEPRECATION") + +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.testTag +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event +import androidx.lifecycle.Lifecycle.Event.ON_CREATE +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.Lifecycle.State +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.INITIALIZED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewTreeLifecycleOwner +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.AndroidViewRendering +import com.squareup.workflow1.ui.BuilderViewFactory +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.Named +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewFactory +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.bindShowRendering +import com.squareup.workflow1.ui.internal.test.DetectLeaksAfterTestSuccess +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.ui.plus +import org.hamcrest.Description +import org.hamcrest.TypeSafeMatcher +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import kotlin.reflect.KClass + +@OptIn(WorkflowUiExperimentalApi::class) +@RunWith(AndroidJUnit4::class) +internal class LegacyWorkflowRenderingTest { + + private val composeRule = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + @Test fun doesNotRecompose_whenFactoryChanged() { + val registry1 = ViewRegistry( + composeViewFactory { rendering, _ -> + BasicText(rendering) + } + ) + val registry2 = ViewRegistry( + composeViewFactory { rendering, _ -> + BasicText(rendering.reversed()) + } + ) + val registry = mutableStateOf(registry1) + + composeRule.setContent { + WorkflowRendering("hello", ViewEnvironment.EMPTY + registry.value) + } + + composeRule.onNodeWithText("hello").assertIsDisplayed() + registry.value = registry2 + composeRule.onNodeWithText("hello").assertIsDisplayed() + 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) : AndroidViewRendering { + override val viewFactory: ViewFactory = when (whichFactory) { + true -> composeViewFactory { _, _ -> BasicText("one") } + false -> composeViewFactory { _, _ -> 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) + + val testFactory = composeViewFactory { rendering, _ -> + BasicText(rendering.text) + } + val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(testFactory) + .withCompositionRoot { content -> + Column { + BasicText("one") + content() + } + } + + composeRule.setContent { + WorkflowRendering(TestRendering("two"), viewEnvironment) + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun legacyAndroidViewRendersUpdates() { + val wrapperText = mutableStateOf("two") + + composeRule.setContent { + WorkflowRendering(LegacyViewRendering(wrapperText.value), ViewEnvironment.EMPTY) + } + + onView(withText("two")).check(matches(isDisplayed())) + wrapperText.value = "OWT" + onView(withText("OWT")).check(matches(isDisplayed())) + } + + // https://github.com/square/workflow-kotlin/issues/538 + @Test fun includesSupportForNamed() { + val wrapperText = mutableStateOf("two") + + composeRule.setContent { + val rendering = Named(LegacyViewRendering(wrapperText.value), "fnord") + WorkflowRendering(rendering, ViewEnvironment.EMPTY) + } + + onView(withText("two")).check(matches(isDisplayed())) + wrapperText.value = "OWT" + onView(withText("OWT")).check(matches(isDisplayed())) + } + + @Test fun destroysChildLifecycle_fromCompose_whenIncompatibleRendering() { + val lifecycleEvents = mutableListOf() + + class LifecycleRecorder : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + lifecycle.addObserver( + LifecycleEventObserver { _, event -> + lifecycleEvents += event + } + ) + onDispose { + // Yes, we're leaking the observer. That's intentional: we need to make sure we see any + // lifecycle events that happen even after the composable is destroyed. + } + } + } + } + + class EmptyRendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) {} + } + + var rendering: Any by mutableStateOf(LifecycleRecorder()) + composeRule.setContent { + WorkflowRendering(rendering, ViewEnvironment.EMPTY) + } + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_CREATE, ON_START, ON_RESUME).inOrder() + lifecycleEvents.clear() + } + + rendering = EmptyRendering() + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_PAUSE, ON_STOP, ON_DESTROY).inOrder() + } + } + + @Test fun destroysChildLifecycle_fromLegacyView_whenIncompatibleRendering() { + val lifecycleEvents = mutableListOf() + + class LifecycleRecorder : AndroidViewRendering { + override val viewFactory: ViewFactory = BuilderViewFactory( + LifecycleRecorder::class + ) { initialRendering, initialViewEnvironment, contextForNewView, _ -> + object : View(contextForNewView) { + init { + bindShowRendering(initialRendering, initialViewEnvironment) { _, _ -> } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val lifecycle = ViewTreeLifecycleOwner.get(this)!!.lifecycle + lifecycle.addObserver( + LifecycleEventObserver { _, event -> + lifecycleEvents += event + } + ) + // Yes, we're leaking the observer. That's intentional: we need to make sure we see + // any lifecycle events that happen even after the composable is destroyed. + } + } + } + } + + class EmptyRendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) {} + } + + var rendering: Any by mutableStateOf(LifecycleRecorder()) + composeRule.setContent { + WorkflowRendering(rendering, ViewEnvironment.EMPTY) + } + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_CREATE, ON_START, ON_RESUME).inOrder() + lifecycleEvents.clear() + } + + rendering = EmptyRendering() + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_PAUSE, ON_STOP, ON_DESTROY).inOrder() + } + } + + @Test fun followsParentLifecycle() { + val states = mutableListOf() + val parentOwner = object : LifecycleOwner { + val registry = LifecycleRegistry(this) + override fun getLifecycle(): Lifecycle = registry + } + + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { + WorkflowRendering(LifecycleRecorder(states), ViewEnvironment.EMPTY) + } + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(INITIALIZED).inOrder() + states.clear() + parentOwner.registry.currentState = STARTED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(CREATED, STARTED).inOrder() + states.clear() + parentOwner.registry.currentState = CREATED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(CREATED).inOrder() + states.clear() + parentOwner.registry.currentState = RESUMED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(STARTED, RESUMED).inOrder() + states.clear() + parentOwner.registry.currentState = DESTROYED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(STARTED, CREATED, DESTROYED).inOrder() + } + } + + @Test fun handlesParentInitiallyDestroyed() { + val states = mutableListOf() + val parentOwner = object : LifecycleOwner { + val registry = LifecycleRegistry(this) + override fun getLifecycle(): Lifecycle = registry + } + composeRule.runOnIdle { + parentOwner.registry.currentState = DESTROYED + } + + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { + WorkflowRendering(LifecycleRecorder(states), ViewEnvironment.EMPTY) + } + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(INITIALIZED).inOrder() + } + } + + @Test fun appliesModifierToComposableContent() { + class Rendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + Box( + Modifier + .testTag("box") + .fillMaxSize() + ) + } + } + + composeRule.setContent { + WorkflowRendering( + Rendering(), ViewEnvironment.EMPTY, + Modifier.size(width = 42.dp, height = 43.dp) + ) + } + + composeRule.onNodeWithTag("box") + .assertWidthIsEqualTo(42.dp) + .assertHeightIsEqualTo(43.dp) + } + + @Test fun propagatesMinConstraints() { + class Rendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + Box(Modifier.testTag("box")) + } + } + + composeRule.setContent { + WorkflowRendering( + Rendering(), ViewEnvironment.EMPTY, + Modifier.sizeIn(minWidth = 42.dp, minHeight = 43.dp) + ) + } + + composeRule.onNodeWithTag("box") + .assertWidthIsEqualTo(42.dp) + .assertHeightIsEqualTo(43.dp) + } + + @Test fun appliesModifierToViewContent() { + val viewId = View.generateViewId() + + class LegacyRendering(private val viewId: Int) : AndroidViewRendering { + override val viewFactory: ViewFactory = BuilderViewFactory( + LegacyRendering::class + ) { initialRendering, initialViewEnvironment, contextForNewView, _ -> + object : View(contextForNewView) { + init { + bindShowRendering(initialRendering, initialViewEnvironment) { r, _ -> + id = r.viewId + } + } + } + } + } + + composeRule.setContent { + with(LocalDensity.current) { + WorkflowRendering( + LegacyRendering(viewId), ViewEnvironment.EMPTY, + Modifier.size(42.toDp(), 43.toDp()) + ) + } + } + + onView(withId(viewId)).check(matches(hasSize(42, 43))) + } + + @Test fun skipsPreviousContentWhenIncompatible() { + var disposeCount = 0 + + class Rendering( + override val compatibilityKey: String + ) : ComposableRendering, Compatible { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + var counter by rememberSaveable { mutableStateOf(0) } + Column { + BasicText( + "$compatibilityKey: $counter", + Modifier + .testTag("tag") + .clickable { counter++ } + ) + DisposableEffect(Unit) { + onDispose { + disposeCount++ + } + } + } + } + } + + var key by mutableStateOf("one") + composeRule.setContent { + WorkflowRendering(Rendering(key), ViewEnvironment.EMPTY) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("one: 0") + .performClick() + .assertTextEquals("one: 1") + + key = "two" + + composeRule.onNodeWithTag("tag") + .assertTextEquals("two: 0") + composeRule.runOnIdle { + assertThat(disposeCount).isEqualTo(1) + } + + key = "one" + + // State should not be restored. + composeRule.onNodeWithTag("tag") + .assertTextEquals("one: 0") + composeRule.runOnIdle { + assertThat(disposeCount).isEqualTo(2) + } + } + + @Test fun doesNotSkipPreviousContentWhenCompatible() { + var disposeCount = 0 + + class Rendering(val text: String) : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + var counter by rememberSaveable { mutableStateOf(0) } + Column { + BasicText( + "$text: $counter", + Modifier + .testTag("tag") + .clickable { counter++ } + ) + DisposableEffect(Unit) { + onDispose { + disposeCount++ + } + } + } + } + } + + var text by mutableStateOf("one") + composeRule.setContent { + WorkflowRendering(Rendering(text), ViewEnvironment.EMPTY) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("one: 0") + .performClick() + .assertTextEquals("one: 1") + + text = "two" + + // Counter state should be preserved. + composeRule.onNodeWithTag("tag") + .assertTextEquals("two: 1") + composeRule.runOnIdle { + assertThat(disposeCount).isEqualTo(0) + } + } + + @Suppress("SameParameterValue") + private fun hasSize( + width: Int, + height: Int + ) = object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("has size ${width}x${height}px") + } + + override fun matchesSafely(item: View): Boolean { + return item.width == width && item.height == height + } + } + + private class LifecycleRecorder( + // For some reason, if we just capture the states val, it is null in the composable. + private val states: MutableList + ) : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + this@LifecycleRecorder.states += lifecycle.currentState + lifecycle.addObserver( + LifecycleEventObserver { _, _ -> + this@LifecycleRecorder.states += lifecycle.currentState + } + ) + onDispose { + // Yes, we're leaking the observer. That's intentional: we need to make sure we see any + // lifecycle events that happen even after the composable is destroyed. + } + } + } + } + + @Suppress("UNCHECKED_CAST") + private interface ComposableRendering> : + AndroidViewRendering { + + /** + * 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: ViewFactory + get() = object : ComposeViewFactory>() { + override val type: KClass> = ComposableRendering::class + + @Composable override fun Content( + rendering: ComposableRendering<*>, + viewEnvironment: ViewEnvironment + ) { + rendering.Content(viewEnvironment) + } + } + + @Composable fun Content(viewEnvironment: ViewEnvironment) + } + + private data class LegacyViewRendering( + val text: String + ) : AndroidViewRendering { + override val viewFactory: ViewFactory = + object : ViewFactory { + override val type = LegacyViewRendering::class + + override fun buildView( + initialRendering: LegacyViewRendering, + initialViewEnvironment: ViewEnvironment, + contextForNewView: Context, + container: ViewGroup? + ): View = TextView(contextForNewView).apply { + bindShowRendering(initialRendering, initialViewEnvironment) { rendering, _ -> + text = rendering.text + } + } + } + } +} diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt index 517965d87c..5b8ebba7cc 100644 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt @@ -1,17 +1,14 @@ -@file:Suppress("DEPRECATION") - package com.squareup.workflow1.ui.compose import android.content.Context import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import com.squareup.workflow1.ui.BuilderViewFactory -import com.squareup.workflow1.ui.ViewFactory +import com.squareup.workflow1.ui.ManualScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.BackStackScreen -import com.squareup.workflow1.ui.backstack.asNonLegacy import com.squareup.workflow1.ui.bindShowRendering import com.squareup.workflow1.ui.container.BackStackContainer +import com.squareup.workflow1.ui.container.BackStackScreen import com.squareup.workflow1.ui.container.R /** @@ -32,8 +29,8 @@ internal class NoTransitionBackStackContainer(context: Context) : BackStackConta addView(newView) } - companion object : ViewFactory> - by BuilderViewFactory( + companion object : ScreenViewFactory> + by ManualScreenViewFactory( type = BackStackScreen::class, viewConstructor = { initialRendering, initialEnv, context, _ -> NoTransitionBackStackContainer(context) @@ -43,9 +40,7 @@ internal class NoTransitionBackStackContainer(context: Context) : BackStackConta bindShowRendering( initialRendering, initialEnv, - { newRendering, newViewEnvironment -> - update(newRendering.asNonLegacy(), newViewEnvironment) - } + { newRendering, newViewEnvironment -> update(newRendering, newViewEnvironment) } ) } } 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 bf32a4f996..7ca7062e43 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 @@ -1,4 +1,4 @@ -@file:Suppress("TestFunctionName", "DEPRECATION") +@file:Suppress("TestFunctionName") package com.squareup.workflow1.ui.compose @@ -58,12 +58,13 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import com.squareup.workflow1.ui.AndroidViewRendering -import com.squareup.workflow1.ui.BuilderViewFactory +import com.squareup.workflow1.ui.AndroidScreen import com.squareup.workflow1.ui.Compatible -import com.squareup.workflow1.ui.Named +import com.squareup.workflow1.ui.ManualScreenViewFactory +import com.squareup.workflow1.ui.NamedScreen +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.bindShowRendering @@ -90,20 +91,24 @@ internal class WorkflowRenderingTest { .around(IdlingDispatcherRule) @Test fun doesNotRecompose_whenFactoryChanged() { + data class TestRendering( + val text: String + ) : Screen + val registry1 = ViewRegistry( - composeViewFactory { rendering, _ -> - BasicText(rendering) + composeScreenViewFactory { rendering, _ -> + BasicText(rendering.text) } ) val registry2 = ViewRegistry( - composeViewFactory { rendering, _ -> - BasicText(rendering.reversed()) + composeScreenViewFactory { rendering, _ -> + BasicText(rendering.text.reversed()) } ) val registry = mutableStateOf(registry1) composeRule.setContent { - WorkflowRendering("hello", ViewEnvironment.EMPTY + registry.value) + WorkflowRendering(TestRendering("hello"), ViewEnvironment.EMPTY + registry.value) } composeRule.onNodeWithText("hello").assertIsDisplayed() @@ -117,10 +122,10 @@ internal class WorkflowRenderingTest { * a new factory when a new rendering is incompatible with the current one. */ @Test fun doesNotRecompose_whenAndroidViewRendering_factoryChanged() { - data class ShiftyRendering(val whichFactory: Boolean) : AndroidViewRendering { - override val viewFactory: ViewFactory = when (whichFactory) { - true -> composeViewFactory { _, _ -> BasicText("one") } - false -> composeViewFactory { _, _ -> BasicText("two") } + data class ShiftyRendering(val whichFactory: Boolean) : AndroidScreen { + override val viewFactory: ScreenViewFactory = when (whichFactory) { + true -> composeScreenViewFactory { _, _ -> BasicText("one") } + false -> composeScreenViewFactory { _, _ -> BasicText("two") } } } @@ -136,12 +141,12 @@ internal class WorkflowRenderingTest { } @Test fun wrapsFactoryWithRoot_whenAlreadyInComposition() { - data class TestRendering(val text: String) + data class TestRendering(val text: String) : Screen - val testFactory = composeViewFactory { rendering, _ -> + val testFactory = composeScreenViewFactory { rendering, _ -> BasicText(rendering.text) } - val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(testFactory) + val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(testFactory)) .withCompositionRoot { content -> Column { BasicText("one") @@ -174,7 +179,7 @@ internal class WorkflowRenderingTest { val wrapperText = mutableStateOf("two") composeRule.setContent { - val rendering = Named(LegacyViewRendering(wrapperText.value), "fnord") + val rendering = NamedScreen(LegacyViewRendering(wrapperText.value), "fnord") WorkflowRendering(rendering, ViewEnvironment.EMPTY) } @@ -207,7 +212,7 @@ internal class WorkflowRenderingTest { @Composable override fun Content(viewEnvironment: ViewEnvironment) {} } - var rendering: Any by mutableStateOf(LifecycleRecorder()) + var rendering: Screen by mutableStateOf(LifecycleRecorder()) composeRule.setContent { WorkflowRendering(rendering, ViewEnvironment.EMPTY) } @@ -227,8 +232,8 @@ internal class WorkflowRenderingTest { @Test fun destroysChildLifecycle_fromLegacyView_whenIncompatibleRendering() { val lifecycleEvents = mutableListOf() - class LifecycleRecorder : AndroidViewRendering { - override val viewFactory: ViewFactory = BuilderViewFactory( + class LifecycleRecorder : AndroidScreen { + override val viewFactory: ScreenViewFactory = ManualScreenViewFactory( LifecycleRecorder::class ) { initialRendering, initialViewEnvironment, contextForNewView, _ -> object : View(contextForNewView) { @@ -255,7 +260,7 @@ internal class WorkflowRenderingTest { @Composable override fun Content(viewEnvironment: ViewEnvironment) {} } - var rendering: Any by mutableStateOf(LifecycleRecorder()) + var rendering: Screen by mutableStateOf(LifecycleRecorder()) composeRule.setContent { WorkflowRendering(rendering, ViewEnvironment.EMPTY) } @@ -380,8 +385,8 @@ internal class WorkflowRenderingTest { @Test fun appliesModifierToViewContent() { val viewId = View.generateViewId() - class LegacyRendering(private val viewId: Int) : AndroidViewRendering { - override val viewFactory: ViewFactory = BuilderViewFactory( + class LegacyRendering(private val viewId: Int) : AndroidScreen { + override val viewFactory: ScreenViewFactory = ManualScreenViewFactory( LegacyRendering::class ) { initialRendering, initialViewEnvironment, contextForNewView, _ -> object : View(contextForNewView) { @@ -537,14 +542,14 @@ internal class WorkflowRenderingTest { @Suppress("UNCHECKED_CAST") private interface ComposableRendering> : - AndroidViewRendering { + 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: ViewFactory - get() = object : ComposeViewFactory>() { + override val viewFactory: ScreenViewFactory + get() = object : ComposeScreenViewFactory>() { override val type: KClass> = ComposableRendering::class @Composable override fun Content( @@ -560,9 +565,9 @@ internal class WorkflowRenderingTest { private data class LegacyViewRendering( val text: String - ) : AndroidViewRendering { - override val viewFactory: ViewFactory = - object : ViewFactory { + ) : AndroidScreen { + override val viewFactory: ScreenViewFactory = + object : ScreenViewFactory { override val type = LegacyViewRendering::class override fun buildView( diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeRendering.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeRendering.kt index dd7d88da8a..c873e2d988 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeRendering.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeRendering.kt @@ -41,6 +41,7 @@ import kotlin.reflect.KClass * your renderings to [ComposeViewFactory] implementations at runtime. */ @WorkflowUiExperimentalApi +@Deprecated("Use ComposeScreen") public interface ComposeRendering : AndroidViewRendering { /** Don't override this, override [Content] instead. */ @@ -74,6 +75,10 @@ public interface ComposeRendering : AndroidViewRendering { * aren't supported. See the [ComposeRendering] class for more information. */ @WorkflowUiExperimentalApi +@Deprecated( + "Use ComposeScreen", + ReplaceWith("ComposeScreen(content)", "com.squareup.workflow1.ui.compose.ComposeScreen") +) public inline fun ComposeRendering( crossinline content: @Composable (ViewEnvironment) -> Unit ): ComposeRendering = object : ComposeRendering { 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 new file mode 100644 index 0000000000..0d5593074a --- /dev/null +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt @@ -0,0 +1,81 @@ +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.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]. + * + * 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 { + * + * @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. + */ +@WorkflowUiExperimentalApi +public interface ComposeScreen : AndroidScreen { + + /** Don't override this, override [Content] instead. */ + override val viewFactory: ScreenViewFactory get() = Companion + + /** + * The composable content of this rendering. This method will be called with the current rendering + * instance as the receiver, any time a new rendering is emitted, or the [viewEnvironment] + * 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) + } + } +} + +/** + * Convenience function for creating anonymous [ComposeScreen]s since composable fun interfaces + * aren't supported. See the [ComposeScreen] class for more information. + */ +@WorkflowUiExperimentalApi +public inline fun ComposeScreen( + crossinline content: @Composable (ViewEnvironment) -> Unit +): ComposeScreen = object : ComposeScreen { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + 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 new file mode 100644 index 0000000000..3388da096b --- /dev/null +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreenViewFactory.kt @@ -0,0 +1,145 @@ +// See https://youtrack.jetbrains.com/issue/KT-31734 +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.view.View +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.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.bindShowRendering +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 [composeViewFactory] 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, + initialViewEnvironment: ViewEnvironment, + contextForNewView: Context, + container: ViewGroup? + ): View = ComposeView(contextForNewView).also { composeView -> + // Update the state whenever a new rendering is emitted. + // This lambda will be executed synchronously before bindShowRendering returns. + composeView.bindShowRendering( + initialRendering, + initialViewEnvironment + ) { rendering, environment -> + // Entry point to the world of Compose. + composeView.setContent { + Content(rendering, environment) + } + } + } +} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeViewFactory.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeViewFactory.kt index 46804e2b14..1009640143 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeViewFactory.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeViewFactory.kt @@ -36,6 +36,13 @@ import kotlin.reflect.KClass * For more details about how to write composable view factories, see [ComposeViewFactory]. */ @WorkflowUiExperimentalApi +@Deprecated( + "Use composeScreenViewFactory", + ReplaceWith( + "composeScreenViewFactory(content)", + "com.squareup.workflow1.ui.compose.composeScreenViewFactory" + ) +) public inline fun composeViewFactory( noinline content: @Composable ( rendering: RenderingT, @@ -110,6 +117,7 @@ internal fun composeViewFactory( * with the [CompositionRoot]. See the documentation on [CompositionRoot] for more information. */ @WorkflowUiExperimentalApi +@Deprecated("Use ComposeScreenViewFactory") public abstract class ComposeViewFactory : ViewFactory { /** 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 066d13c508..d110272eec 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 @@ -1,4 +1,4 @@ -@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry", "DEPRECATION", "FunctionName") +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry", "DEPRECATION") package com.squareup.workflow1.ui.compose @@ -7,6 +7,9 @@ import androidx.annotation.VisibleForTesting.PRIVATE 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.ViewFactory import com.squareup.workflow1.ui.ViewRegistry @@ -19,14 +22,15 @@ import kotlin.reflect.KClass private val LocalHasViewFactoryRootBeenApplied = staticCompositionLocalOf { false } /** - * A composable function that will be used to wrap the first (highest-level) [composeViewFactory] - * view factory in a composition. This can be used to setup any - * [composition locals][androidx.compose.runtime.CompositionLocal] that all [composeViewFactory] - * factories need access to, such as UI themes. + * 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 + * [composition locals][androidx.compose.runtime.CompositionLocal] that all + * [composeScreenViewFactory] factories need access to, such as UI themes. * - * This function will called once, to wrap the _highest-level_ [composeViewFactory] in the tree. - * However, composition locals are propagated down to child [composeViewFactory] compositions, so - * any locals provided here will be available in _all_ [composeViewFactory] compositions. + * 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] + * compositions. */ public typealias CompositionRoot = @Composable (content: @Composable () -> Unit) -> Unit @@ -35,8 +39,11 @@ public typealias CompositionRoot = @Composable (content: @Composable () -> Unit) * See [ViewRegistry.withCompositionRoot]. */ @WorkflowUiExperimentalApi -public fun ViewEnvironment.withCompositionRoot(root: CompositionRoot): ViewEnvironment = - this + (ViewRegistry to this[ViewRegistry].withCompositionRoot(root)) +public fun ViewEnvironment.withCompositionRoot(root: CompositionRoot): ViewEnvironment { + return this + + (ScreenViewFactoryFinder to this[ScreenViewFactoryFinder].withCompositionRoot(root)) + + (ViewRegistry to this[ViewRegistry].withCompositionRoot(root)) +} /** * Returns a [ViewRegistry] that ensures that any [composeViewFactory] factories registered in this @@ -44,6 +51,7 @@ public fun ViewEnvironment.withCompositionRoot(root: CompositionRoot): ViewEnvir * See [CompositionRoot] for more information. */ @WorkflowUiExperimentalApi +@Deprecated("Use ScreenViewFactoryFinder.withCompositionRoot") public fun ViewRegistry.withCompositionRoot(root: CompositionRoot): ViewRegistry = mapFactories { factory -> @Suppress("UNCHECKED_CAST") @@ -57,6 +65,25 @@ public fun ViewRegistry.withCompositionRoot(root: CompositionRoot): ViewRegistry } ?: factory } +/** + * Returns a [ScreenViewFactoryFinder] that ensures that any [composeScreenViewFactory] + * factories registered in this registry will be wrapped exactly once with a [CompositionRoot] + * wrapper. See [CompositionRoot] for more information. + */ +@WorkflowUiExperimentalApi +public fun ScreenViewFactoryFinder.withCompositionRoot( + root: CompositionRoot +): ScreenViewFactoryFinder = + mapFactories { factory -> + @Suppress("UNCHECKED_CAST") + (factory as? ComposeScreenViewFactory)?.let { composeFactory -> + @Suppress("UNCHECKED_CAST") + composeScreenViewFactory(composeFactory.type) { rendering, environment -> + WrappedWithRootIfNecessary(root) { composeFactory.Content(rendering, environment) } + } + } ?: factory + } + /** * Adds [content] to the composition, ensuring that [CompositionRoot] has been applied. Will only * wrap the content at the highest occurrence of this function in the composition subtree. @@ -86,6 +113,7 @@ public fun ViewRegistry.withCompositionRoot(root: CompositionRoot): ViewRegistry * at the time of lookup via [ViewRegistry.getEntryFor]. */ @WorkflowUiExperimentalApi +@Deprecated("Use ScreenViewFactoryFinder.mapFactories") private fun ViewRegistry.mapFactories( transform: (ViewFactory<*>) -> ViewFactory<*> ): ViewRegistry = object : ViewRegistry { @@ -93,10 +121,11 @@ private fun ViewRegistry.mapFactories( override fun getEntryFor( renderingType: KClass - ): ViewFactory? { - val factoryFor = (this@mapFactories.getEntryFor(renderingType) as? ViewFactory<*>) - ?: return null - val transformedFactory = transform(factoryFor) + ): ViewRegistry.Entry? { + val rawEntry = this@mapFactories.getEntryFor(renderingType) + val asViewFactory = (rawEntry as? ViewFactory<*>) ?: return rawEntry + + val transformedFactory = transform(asViewFactory) check(transformedFactory.type == renderingType) { "Expected transform to return a ViewFactory that is compatible with $renderingType, " + "but got one with type ${transformedFactory.type}" @@ -105,3 +134,22 @@ private fun ViewRegistry.mapFactories( return transformedFactory as ViewFactory } } + +@WorkflowUiExperimentalApi +private fun ScreenViewFactoryFinder.mapFactories( + transform: (ScreenViewFactory<*>) -> ScreenViewFactory<*> +): ScreenViewFactoryFinder = object : ScreenViewFactoryFinder { + override fun getViewFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenViewFactory { + val factoryFor = this@mapFactories.getViewFactoryForRendering(environment, rendering) + val transformedFactory = transform(factoryFor) + check(transformedFactory.type == rendering::class) { + "Expected transform to return a ScreenViewFactory that is compatible " + + "with ${rendering::class}, but got one with type ${transformedFactory.type}" + } + @Suppress("UNCHECKED_CAST") + return transformedFactory as ScreenViewFactory + } +} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/LegacyWorkflowRendering.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/LegacyWorkflowRendering.kt new file mode 100644 index 0000000000..a12415936a --- /dev/null +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/LegacyWorkflowRendering.kt @@ -0,0 +1,185 @@ +@file:Suppress("DEPRECATION") + +package com.squareup.workflow1.ui.compose + +import android.view.View +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +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.ViewTreeLifecycleOwner +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewFactory +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.WorkflowViewStub +import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner +import com.squareup.workflow1.ui.getFactoryForRendering +import com.squareup.workflow1.ui.getShowRendering +import com.squareup.workflow1.ui.showRendering +import com.squareup.workflow1.ui.start +import kotlin.reflect.KClass + +@Deprecated("Use the overload with a `rendering: Screen` parameter") +@WorkflowUiExperimentalApi +@Composable public fun WorkflowRendering( + rendering: Any, + viewEnvironment: ViewEnvironment, + modifier: Modifier = Modifier +) { + // This will fetch a new view factory any time the new rendering is incompatible with the previous + // one, as determined by Compatible. This corresponds to WorkflowViewStub's canShowRendering + // check. + val renderingCompatibilityKey = Compatible.keyFor(rendering) + + // By surrounding the below code with this key function, any time the new rendering is not + // compatible with the previous rendering we'll tear down the previous subtree of the composition, + // including its lifecycle, which destroys the lifecycle and any remembered state. If the view + // 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 { + // The view registry may return a new factory instance for a rendering every time we ask it, for + // example if an AndroidViewRendering 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. + viewEnvironment[ViewRegistry] + // Can't use ViewRegistry.buildView here since we need the factory to convert it to a + // compose one. + .getFactoryForRendering(rendering) + .asComposeViewFactory() + } + + // Just like WorkflowViewStub, we need to manage a Lifecycle for the child view. We just provide + // a local here – ViewFactoryAndroidView will handle setting the appropriate view tree owners + // on the child view when necessary. Because this function is surrounded by a key() call, when + // the rendering is incompatible, the lifecycle for the old view will be destroyed. + val lifecycleOwner = rememberChildLifecycleOwner() + + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + // We need to propagate min constraints because one of the likely uses for the modifier passed + // 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) + } + } + } +} + +/** + * Returns a [LifecycleOwner] that is a mirror of the current [LocalLifecycleOwner] until this + * function leaves the composition. Similar to [WorkflowLifecycleOwner] for views, but a + * bit simpler since we don't need to worry about attachment state. + */ +@Composable private fun rememberChildLifecycleOwner(): LifecycleOwner { + val lifecycleOwner = remember { + object : LifecycleOwner { + val registry = LifecycleRegistry(this) + override fun getLifecycle(): Lifecycle = registry + } + } + val parentLifecycle = LocalLifecycleOwner.current.lifecycle + + DisposableEffect(parentLifecycle) { + val parentObserver = LifecycleEventObserver { _, event -> + // Any time the parent lifecycle changes state, perform the same change on our lifecycle. + lifecycleOwner.registry.handleLifecycleEvent(event) + } + + parentLifecycle.addObserver(parentObserver) + onDispose { + parentLifecycle.removeObserver(parentObserver) + + // If we're leaving the composition it means the WorkflowRendering is either going away itself + // or about to switch to an incompatible rendering – either way, this lifecycle is dead. Note + // that we can't transition from INITIALIZED to DESTROYED – the LifecycelRegistry will throw. + // WorkflowLifecycleOwner has this same check. + if (lifecycleOwner.registry.currentState != INITIALIZED) { + lifecycleOwner.registry.currentState = DESTROYED + } + } + } + + return lifecycleOwner +} + +/** + * Returns a [ComposeViewFactory] that makes it convenient to display this [ViewFactory] as a + * composable. If this is a [ComposeViewFactory] already it just returns `this`, otherwise it wraps + * the factory in one that manages a classic Android view. + */ +@OptIn(WorkflowUiExperimentalApi::class) +private fun ViewFactory.asComposeViewFactory() = + (this as? ComposeViewFactory) ?: object : ComposeViewFactory() { + + 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 [ViewFactory], since + * we already have the correct one. + * - Propagate the current [LifecycleOwner] from [LocalLifecycleOwner] by setting it as the + * [ViewTreeLifecycleOwner] on the view. + * + * 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: R, + viewEnvironment: ViewEnvironment + ) { + val lifecycleOwner = LocalLifecycleOwner.current + + 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.buildView(rendering, viewEnvironment, context, container = null) + .also { view -> + view.start() + + // Mirrors the check done in ViewRegistry.buildView. + checkNotNull(view.getShowRendering()) { + "View.bindShowRendering should have been called for $view, typically by the " + + "${ViewFactory::class.java.name} that created it." + } + + // Unfortunately AndroidView doesn't propagate this itself. + ViewTreeLifecycleOwner.set(view, lifecycleOwner) + // 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. + } + }, + // 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 -> + view.showRendering(rendering, viewEnvironment) + } + ) + } + } 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 728373248f..9bc0c50c65 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,5 +1,4 @@ @file:Suppress("DEPRECATION") - package com.squareup.workflow1.ui.compose import android.view.View @@ -20,13 +19,15 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.ViewTreeLifecycleOwner 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.ViewEnvironment import com.squareup.workflow1.ui.ViewFactory import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.WorkflowViewStub import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner -import com.squareup.workflow1.ui.getFactoryForRendering import com.squareup.workflow1.ui.getShowRendering import com.squareup.workflow1.ui.showRendering import com.squareup.workflow1.ui.start @@ -64,7 +65,7 @@ import kotlin.reflect.KClass */ @WorkflowUiExperimentalApi @Composable public fun WorkflowRendering( - rendering: Any, + rendering: Screen, viewEnvironment: ViewEnvironment, modifier: Modifier = Modifier ) { @@ -81,14 +82,12 @@ import kotlin.reflect.KClass key(renderingCompatibilityKey) { val viewFactory = remember { // The view registry may return a new factory instance for a rendering every time we ask it, for - // example if an AndroidViewRendering doesn't share its factory between rendering instances. We + // 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. - viewEnvironment[ViewRegistry] - // Can't use ViewRegistry.buildView here since we need the factory to convert it to a - // compose one. - .getFactoryForRendering(rendering) + viewEnvironment[ScreenViewFactoryFinder] + .getViewFactoryForRendering(viewEnvironment, rendering) .asComposeViewFactory() } @@ -135,7 +134,7 @@ import kotlin.reflect.KClass // If we're leaving the composition it means the WorkflowRendering is either going away itself // or about to switch to an incompatible rendering – either way, this lifecycle is dead. Note - // that we can't transition from INITIALIZED to DESTROYED – the LifecycelRegistry will throw. + // that we can't transition from INITIALIZED to DESTROYED – the LifecycleRegistry will throw. // WorkflowLifecycleOwner has this same check. if (lifecycleOwner.registry.currentState != INITIALIZED) { lifecycleOwner.registry.currentState = DESTROYED @@ -147,13 +146,13 @@ import kotlin.reflect.KClass } /** - * Returns a [ComposeViewFactory] that makes it convenient to display this [ViewFactory] as a - * composable. If this is a [ComposeViewFactory] already it just returns `this`, otherwise it wraps - * the factory in one that manages a classic Android view. + * 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 ViewFactory.asComposeViewFactory() = - (this as? ComposeViewFactory) ?: object : ComposeViewFactory() { +private fun ScreenViewFactory.asComposeViewFactory() = + (this as? ComposeScreenViewFactory) ?: object : ComposeScreenViewFactory() { private val originalFactory = this@asComposeViewFactory override val type: KClass get() = originalFactory.type @@ -192,7 +191,7 @@ private fun ViewFactory.asComposeViewFactory() = // Mirrors the check done in ViewRegistry.buildView. checkNotNull(view.getShowRendering()) { "View.bindShowRendering should have been called for $view, typically by the " + - "${ViewFactory::class.java.name} that created it." + "ScreenViewFactory that created it." } // Unfortunately AndroidView doesn't propagate this itself. diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index 473db3086f..03c44fa1a6 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -602,7 +602,7 @@ public abstract class com/squareup/workflow1/ui/container/ModalScreenOverlayDial public final fun buildDialog (Lcom/squareup/workflow1/ui/container/ScreenOverlay;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroid/content/Context;)Landroid/app/Dialog; public abstract fun buildDialogWithContentView (Landroid/view/View;)Landroid/app/Dialog; public fun getType ()Lkotlin/reflect/KClass; - public abstract fun updateBounds (Landroid/app/Dialog;Landroid/graphics/Rect;)V + public fun updateBounds (Landroid/app/Dialog;Landroid/graphics/Rect;)V public synthetic fun updateDialog (Landroid/app/Dialog;Lcom/squareup/workflow1/ui/container/Overlay;Lcom/squareup/workflow1/ui/ViewEnvironment;)V public final fun updateDialog (Landroid/app/Dialog;Lcom/squareup/workflow1/ui/container/ScreenOverlay;Lcom/squareup/workflow1/ui/ViewEnvironment;)V } 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 cc3f0f8a7f..9f5f5312f2 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 @@ -54,7 +54,7 @@ public interface ScreenViewFactoryFinder { ): ScreenViewFactory { val entry = environment[ViewRegistry].getEntryFor(rendering::class) - @Suppress("UNCHECKED_CAST", "DEPRECATION") + @Suppress("UNCHECKED_CAST") return (entry as? ScreenViewFactory) ?: (rendering as? AndroidScreen<*>)?.viewFactory as? ScreenViewFactory ?: (rendering as? AsScreen<*>)?.let { AsScreenViewFactory as ScreenViewFactory } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayDialogFactory.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayDialogFactory.kt index 399a62d0fc..501b34c787 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayDialogFactory.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/container/ModalScreenOverlayDialogFactory.kt @@ -51,12 +51,17 @@ public abstract class ModalScreenOverlayDialogFactory>( * that are outside of the "shadow" of a modal dialog. Imagine an app * with a status bar that should not be covered by modals. * + * The default implementation calls straight through to [Dialog.setBounds]. + * Custom implementations are not required to call `super`. + * * @see Dialog.setBounds */ - public abstract fun updateBounds( + public open fun updateBounds( dialog: Dialog, bounds: Rect - ) + ) { + dialog.setBounds(bounds) + } final override fun buildDialog( initialRendering: O, diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/AsScreen.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/AsScreen.kt index 070da05761..1195568a41 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/AsScreen.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/AsScreen.kt @@ -25,6 +25,7 @@ public class AsScreen( /** * Ensures [rendering] implements [Screen], wrapping it in an [AsScreen] if necessary. */ +@Suppress("DeprecatedCallableAddReplaceWith") @Deprecated("Implement Screen directly.") @WorkflowUiExperimentalApi public fun asScreen(rendering: Any): Screen { 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 eb4f83de99..987ce84f40 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,5 +1,3 @@ -@file:Suppress("FunctionName") - package com.squareup.workflow1.ui import com.squareup.workflow1.ui.ViewRegistry.Entry 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 4761de6eba..b3810c20fc 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 @@ -70,7 +70,6 @@ internal class CompositeViewRegistryTest { private object BarRendering private object BazRendering - @Suppress("DEPRECATION") private class TestRegistry(private val factories: Map, Entry<*>>) : ViewRegistry { constructor(keys: Set>) : this(keys.associateWith { TestEntry(it) }) diff --git a/workflow-ui/internal-testing-android/src/main/java/com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity.kt b/workflow-ui/internal-testing-android/src/main/java/com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity.kt index 54791b6db8..881abc8ad2 100644 --- a/workflow-ui/internal-testing-android/src/main/java/com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity.kt +++ b/workflow-ui/internal-testing-android/src/main/java/com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity.kt @@ -1,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package com.squareup.workflow1.ui.internal.test import android.content.Context