diff --git a/.buildscript/android-sample-app.gradle b/.buildscript/android-sample-app.gradle index 6b99ace9..e9203bce 100644 --- a/.buildscript/android-sample-app.gradle +++ b/.buildscript/android-sample-app.gradle @@ -1,8 +1,8 @@ apply from: rootProject.file('.buildscript/configure-android-defaults.gradle') dependencies { + implementation(project(":compose-tooling")) implementation(Deps.get("androidx.appcompat")) - implementation(Deps.get("compose.tooling")) implementation(Deps.get("timber")) implementation(Deps.get("workflow.core")) implementation(Deps.get("workflow.runtime")) diff --git a/compose-tooling/api/compose-tooling.api b/compose-tooling/api/compose-tooling.api new file mode 100644 index 00000000..685ec830 --- /dev/null +++ b/compose-tooling/api/compose-tooling.api @@ -0,0 +1,17 @@ +public final class com/squareup/workflow/ui/compose/tooling/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public fun ()V +} + +public final class com/squareup/workflow/ui/compose/tooling/ComposeWorkflowsKt { + public static final fun preview (Lcom/squareup/workflow/compose/ComposeWorkflow;Ljava/lang/Object;Landroidx/ui/core/Modifier;Landroidx/ui/core/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/Composer;)V + public static synthetic fun preview$default (Lcom/squareup/workflow/compose/ComposeWorkflow;Ljava/lang/Object;Landroidx/ui/core/Modifier;Landroidx/ui/core/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/Composer;ILjava/lang/Object;)V +} + +public final class com/squareup/workflow/ui/compose/tooling/ViewFactoriesKt { + public static final fun preview (Lcom/squareup/workflow/ui/ViewFactory;Ljava/lang/Object;Landroidx/ui/core/Modifier;Landroidx/ui/core/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/Composer;)V + public static synthetic fun preview$default (Lcom/squareup/workflow/ui/ViewFactory;Ljava/lang/Object;Landroidx/ui/core/Modifier;Landroidx/ui/core/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/Composer;ILjava/lang/Object;)V +} + diff --git a/compose-tooling/build.gradle.kts b/compose-tooling/build.gradle.kts new file mode 100644 index 00000000..299b6230 --- /dev/null +++ b/compose-tooling/build.gradle.kts @@ -0,0 +1,43 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("com.android.library") + kotlin("android") +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +apply(from = rootProject.file(".buildscript/configure-maven-publish.gradle")) +apply(from = rootProject.file(".buildscript/configure-android-defaults.gradle")) +apply(from = rootProject.file(".buildscript/android-ui-tests.gradle")) + +apply(from = rootProject.file(".buildscript/configure-compose.gradle")) +tasks.withType { + kotlinOptions.apiVersion = "1.3" +} + +dependencies { + api(project(":core-compose")) + api(Dependencies.Compose.tooling) + api(Dependencies.Kotlin.stdlib) + + implementation(Dependencies.Compose.foundation) +} diff --git a/compose-tooling/gradle.properties b/compose-tooling/gradle.properties new file mode 100644 index 00000000..a2027bd0 --- /dev/null +++ b/compose-tooling/gradle.properties @@ -0,0 +1,18 @@ +# +# Copyright 2020 Square Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +POM_ARTIFACT_ID=workflow-ui-compose-tooling +POM_NAME=Workflow UI Compose Tooling +POM_PACKAGING=aar diff --git a/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewComposeWorkflowTest.kt b/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewComposeWorkflowTest.kt new file mode 100644 index 00000000..7feab89d --- /dev/null +++ b/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewComposeWorkflowTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("TestFunctionName", "PrivatePropertyName") + +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.Composable +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.ui.core.Modifier +import androidx.ui.foundation.Text +import androidx.ui.layout.Column +import androidx.ui.layout.size +import androidx.ui.semantics.Semantics +import androidx.ui.test.assertIsDisplayed +import androidx.ui.test.assertIsNotDisplayed +import androidx.ui.test.createComposeRule +import androidx.ui.test.findByText +import androidx.ui.tooling.preview.Preview +import androidx.ui.unit.dp +import com.squareup.workflow.Workflow +import com.squareup.workflow.compose.composed +import com.squareup.workflow.ui.ViewEnvironmentKey +import com.squareup.workflow.ui.compose.showRendering +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Duplicate of [PreviewViewFactoryTest] but for [com.squareup.workflow.compose.ComposeWorkflow]. + */ +@RunWith(AndroidJUnit4::class) +class PreviewComposeWorkflowTest { + + @Rule @JvmField val composeRule = createComposeRule() + + @Test fun singleChild() { + composeRule.setContent { + ParentWithOneChildPreview() + } + + findByText("one").assertIsDisplayed() + findByText("two").assertIsDisplayed() + } + + @Test fun twoChildren() { + composeRule.setContent { + ParentWithTwoChildrenPreview() + } + + findByText("one").assertIsDisplayed() + findByText("two").assertIsDisplayed() + findByText("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. + findByText("one").assertIsNotDisplayed() + findByText("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. + findByText("one").assertIsDisplayed() + findByText("two").assertIsNotDisplayed() + } + + @Test fun customViewEnvironment() { + composeRule.setContent { + ParentConsumesCustomKeyPreview() + } + + findByText("foo").assertIsDisplayed() + } + + private val ParentWithOneChild = + Workflow.composed, Nothing> { props, _, environment -> + Column { + Text(props.first) + Semantics(container = true, mergeAllDescendants = true) { + environment.showRendering(rendering = props.second) + } + } + } + + @Preview @Composable private fun ParentWithOneChildPreview() { + ParentWithOneChild.preview(Pair("one", "two")) + } + + private val ParentWithTwoChildren = + Workflow.composed, Nothing> { props, _, environment -> + Column { + Semantics(container = true) { + environment.showRendering(rendering = props.first) + } + Text(props.second) + Semantics(container = true) { + environment.showRendering(rendering = props.third) + } + } + } + + @Preview @Composable private fun ParentWithTwoChildrenPreview() { + ParentWithTwoChildren.preview(Triple("one", "two", "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 = Workflow.composed { _, _, environment -> + Text(environment[TestEnvironmentKey]) + } + + @Preview @Composable private fun ParentConsumesCustomKeyPreview() { + ParentConsumesCustomKey.preview(Unit) { + it + (TestEnvironmentKey to "foo") + } + } +} diff --git a/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewViewFactoryTest.kt b/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewViewFactoryTest.kt new file mode 100644 index 00000000..cab900a5 --- /dev/null +++ b/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewViewFactoryTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("TestFunctionName", "PrivatePropertyName") + +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.Composable +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.ui.core.Modifier +import androidx.ui.foundation.Text +import androidx.ui.layout.Column +import androidx.ui.layout.size +import androidx.ui.semantics.Semantics +import androidx.ui.test.assertIsDisplayed +import androidx.ui.test.assertIsNotDisplayed +import androidx.ui.test.createComposeRule +import androidx.ui.test.findByText +import androidx.ui.tooling.preview.Preview +import androidx.ui.unit.dp +import com.squareup.workflow.ui.ViewEnvironmentKey +import com.squareup.workflow.ui.compose.bindCompose +import com.squareup.workflow.ui.compose.showRendering +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PreviewViewFactoryTest { + + @Rule @JvmField val composeRule = createComposeRule() + + @Test fun singleChild() { + composeRule.setContent { + ParentWithOneChildPreview() + } + + findByText("one").assertIsDisplayed() + findByText("two").assertIsDisplayed() + } + + @Test fun twoChildren() { + composeRule.setContent { + ParentWithTwoChildrenPreview() + } + + findByText("one").assertIsDisplayed() + findByText("two").assertIsDisplayed() + findByText("three").assertIsDisplayed() + } + + @Test fun recursive() { + composeRule.setContent { + ParentRecursivePreview() + } + + findByText("one").assertIsDisplayed() + findByText("two").assertIsDisplayed() + findByText("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. + findByText("one").assertIsNotDisplayed() + findByText("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. + findByText("one").assertIsDisplayed() + findByText("two").assertIsNotDisplayed() + } + + @Test fun customViewEnvironment() { + composeRule.setContent { + ParentConsumesCustomKeyPreview() + } + + findByText("foo").assertIsDisplayed() + } + + private val ParentWithOneChild = bindCompose> { rendering, environment -> + Column { + Text(rendering.first) + Semantics(container = true, mergeAllDescendants = true) { + environment.showRendering(rendering = rendering.second) + } + } + } + + @Preview @Composable private fun ParentWithOneChildPreview() { + ParentWithOneChild.preview(Pair("one", "two")) + } + + private val ParentWithTwoChildren = + bindCompose> { rendering, environment -> + Column { + Semantics(container = true) { + environment.showRendering(rendering = rendering.first) + } + Text(rendering.second) + Semantics(container = true) { + environment.showRendering(rendering = rendering.third) + } + } + } + + @Preview @Composable private fun ParentWithTwoChildrenPreview() { + ParentWithTwoChildren.preview(Triple("one", "two", "three")) + } + + data class RecursiveRendering( + val text: String, + val child: RecursiveRendering? = null + ) + + private val ParentRecursive = bindCompose { rendering, environment -> + Column { + Text(rendering.text) + rendering.child?.let { child -> + Semantics(container = true) { + environment.showRendering(rendering = child) + } + } + } + } + + @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 = bindCompose { _, environment -> + Text(environment[TestEnvironmentKey]) + } + + @Preview @Composable private fun ParentConsumesCustomKeyPreview() { + ParentConsumesCustomKey.preview(Unit) { + it + (TestEnvironmentKey to "foo") + } + } +} diff --git a/compose-tooling/src/main/AndroidManifest.xml b/compose-tooling/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7b52cef9 --- /dev/null +++ b/compose-tooling/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + diff --git a/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ComposeWorkflows.kt b/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ComposeWorkflows.kt new file mode 100644 index 00000000..c01324ed --- /dev/null +++ b/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ComposeWorkflows.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.Composable +import androidx.ui.core.Modifier +import androidx.ui.foundation.Box +import com.squareup.workflow.Sink +import com.squareup.workflow.compose.ComposeWorkflow +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.ViewRegistry + +/** + * Draws this [ComposeWorkflow] using a special preview [ViewRegistry]. + * + * Use inside `@Preview` Composable functions. + * + * *Note: [props] must be the `PropsT` of this [ComposeWorkflow], 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. + */ +// TODO(https://issuetracker.google.com/issues/156527332) Should be ViewFactory +@Composable fun ComposeWorkflow<*, *>.preview( + props: PropsT, + modifier: Modifier = Modifier, + placeholderModifier: Modifier = Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null +) { + val previewEnvironment = previewViewEnvironment(placeholderModifier, viewEnvironmentUpdater) + Box(modifier = modifier) { + // Cast is needed due to bug that prevents the receiver from using PropsT. + @Suppress("UNCHECKED_CAST") + (this as ComposeWorkflow).render(props, NoopSink, previewEnvironment) + } +} + +private object NoopSink : Sink { + override fun send(value: Any) = Unit +} diff --git a/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PlaceholderViewFactory.kt b/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PlaceholderViewFactory.kt new file mode 100644 index 00000000..75056be8 --- /dev/null +++ b/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PlaceholderViewFactory.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("SameParameterValue", "DEPRECATION") + +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.Composable +import androidx.ui.core.DrawScope +import androidx.ui.core.Modifier +import androidx.ui.core.clipToBounds +import androidx.ui.core.drawBehind +import androidx.ui.foundation.Box +import androidx.ui.foundation.Text +import androidx.ui.foundation.drawBorder +import androidx.ui.geometry.Offset +import androidx.ui.graphics.Color +import androidx.ui.graphics.Paint +import androidx.ui.graphics.Shadow +import androidx.ui.graphics.withSave +import androidx.ui.graphics.withSaveLayer +import androidx.ui.layout.fillMaxSize +import androidx.ui.text.TextStyle +import androidx.ui.text.style.TextAlign +import androidx.ui.tooling.preview.Preview +import androidx.ui.unit.Dp +import androidx.ui.unit.dp +import androidx.ui.unit.px +import androidx.ui.unit.toRect +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.compose.bindCompose + +/** + * A [ViewFactory] that will be used any time a [PreviewViewRegistry] is asked to show a rendering. + * It displays a placeholder graphic and the rendering's `toString()` result. + */ +internal fun placeholderViewFactory(modifier: Modifier): ViewFactory = + bindCompose { rendering, _ -> + Text( + modifier = modifier/*.fillMaxSize()*/ + .clipToBounds() + .drawBehind { + withSaveLayer(size.toRect(), Paint().apply { alpha = .2f }) { + drawRect(size.toRect(), Paint().apply { color = Color.Gray }) + drawCrossHatch( + color = Color.Red, + strokeWidth = 2.dp, + spaceWidth = 5.dp, + angle = 45f + ) + } + }, + text = rendering.toString(), + style = TextStyle( + textAlign = TextAlign.Center, + color = Color.White, + shadow = Shadow(blurRadius = 5.px, color = Color.Black) + ) + ) + } + +@Preview(widthDp = 200, heightDp = 200) +@Composable private fun PreviewStubViewBindingOnWhite() { + Box(backgroundColor = Color.White) { + placeholderViewFactory(Modifier).preview( + rendering = "preview", + modifier = Modifier.fillMaxSize() + .drawBorder(size = 1.dp, color = Color.Red) + ) + } +} + +@Preview(widthDp = 200, heightDp = 200) +@Composable private fun PreviewStubViewBindingOnBlack() { + Box(backgroundColor = Color.Black) { + placeholderViewFactory(Modifier).preview( + rendering = "preview", + modifier = Modifier.fillMaxSize() + .drawBorder(size = 1.dp, color = Color.Red) + ) + } +} + +private fun DrawScope.drawCrossHatch( + color: Color, + strokeWidth: Dp, + spaceWidth: Dp, + angle: Float +) { + drawHatch(color, strokeWidth, spaceWidth, angle) + drawHatch(color, strokeWidth, spaceWidth, angle + 90) +} + +private fun DrawScope.drawHatch( + color: Color, + strokeWidth: Dp, + spaceWidth: Dp, + angle: Float +) { + val strokeWidthPx = strokeWidth.toPx() + .value + val paint = Paint().also { + it.color = color.scaleColors(.5f) + it.strokeWidth = strokeWidthPx + } + + withSave { + val halfWidth = size.width.value / 2 + val halfHeight = size.height.value / 2 + translate(halfWidth, halfHeight) + rotate(angle) + translate(-halfWidth, -halfHeight) + + // Draw outside our bounds to fill the space even when rotated. + val left = -size.width.value + val right = size.width.value * 2 + val top = -size.height.value + val bottom = size.height.value * 2 + + var y = top + strokeWidthPx * 2f + while (y < bottom) { + drawLine( + Offset(left, y), + Offset(right, y), + paint + ) + y += spaceWidth.toPx().value * 2 + } + } +} + +private fun Color.scaleColors(factor: Float) = + copy(red = red * factor, green = green * factor, blue = blue * factor) diff --git a/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PreviewViewEnvironment.kt b/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PreviewViewEnvironment.kt new file mode 100644 index 00000000..05a86cf8 --- /dev/null +++ b/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PreviewViewEnvironment.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.Composable +import androidx.compose.Immutable +import androidx.compose.remember +import androidx.ui.core.Modifier +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.ViewRegistry +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. + */ +@Composable internal fun previewViewEnvironment( + placeholderModifier: Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null, + mainFactory: ViewFactory<*>? = null +): ViewEnvironment { + val viewRegistry = remember(mainFactory, placeholderModifier) { + PreviewViewRegistry(mainFactory, placeholderViewFactory(placeholderModifier)) + } + return remember(viewRegistry, viewEnvironmentUpdater) { + ViewEnvironment(viewRegistry).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 [showRendering][com.squareup.workflow.ui.compose.showRendering] calls. + */ +@Immutable +private class PreviewViewRegistry( + private val mainFactory: ViewFactory? = null, + private val placeholderFactory: ViewFactory +) : ViewRegistry { + override val keys: Set> get() = mainFactory?.let { setOf(it.type) } ?: emptySet() + + @Suppress("UNCHECKED_CAST") + override fun getFactoryFor( + renderingType: KClass + ): ViewFactory = when (renderingType) { + mainFactory?.type -> mainFactory + else -> placeholderFactory + } as ViewFactory +} diff --git a/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ViewFactories.kt b/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ViewFactories.kt new file mode 100644 index 00000000..4314486f --- /dev/null +++ b/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ViewFactories.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.Composable +import androidx.ui.core.Modifier +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.compose.showRendering + +/** + * 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. + */ +// TODO(https://issuetracker.google.com/issues/156527332) Should be ViewFactory +@Composable fun ViewFactory<*>.preview( + rendering: RenderingT, + modifier: Modifier = Modifier, + placeholderModifier: Modifier = Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null +) { + val previewEnvironment = + previewViewEnvironment(placeholderModifier, viewEnvironmentUpdater, mainFactory = this) + previewEnvironment.showRendering(rendering, modifier) +} diff --git a/core-compose/api/core-compose.api b/core-compose/api/core-compose.api index 579885d8..9b005b93 100644 --- a/core-compose/api/core-compose.api +++ b/core-compose/api/core-compose.api @@ -15,6 +15,10 @@ public abstract class com/squareup/workflow/compose/ComposeWorkflow : com/square public abstract fun render (Ljava/lang/Object;Lcom/squareup/workflow/Sink;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/Composer;)V } +public final class com/squareup/workflow/compose/ComposeWorkflowKt { + public static final fun composed (Lcom/squareup/workflow/Workflow$Companion;Lkotlin/jvm/functions/Function4;)Lcom/squareup/workflow/compose/ComposeWorkflow; +} + public final class com/squareup/workflow/ui/compose/ComposeSupportKt { public static final fun ()V } diff --git a/core-compose/src/main/java/com/squareup/workflow/compose/ComposeWorkflow.kt b/core-compose/src/main/java/com/squareup/workflow/compose/ComposeWorkflow.kt index abfbcd48..3f435d22 100644 --- a/core-compose/src/main/java/com/squareup/workflow/compose/ComposeWorkflow.kt +++ b/core-compose/src/main/java/com/squareup/workflow/compose/ComposeWorkflow.kt @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + package com.squareup.workflow.compose import androidx.compose.Composable @@ -55,3 +57,22 @@ abstract class ComposeWorkflow : override fun asStatefulWorkflow(): StatefulWorkflow = ComposeWorkflowImpl(this) } + +/** + * Returns a [ComposeWorkflow] that renders itself using the given [render] function. + */ +inline fun Workflow.Companion.composed( + crossinline render: @Composable() ( + props: PropsT, + outputSink: Sink, + environment: ViewEnvironment + ) -> Unit +): ComposeWorkflow = object : ComposeWorkflow() { + @Composable override fun render( + props: PropsT, + outputSink: Sink, + viewEnvironment: ViewEnvironment + ) { + render(props, outputSink, viewEnvironment) + } +} diff --git a/core-compose/src/main/java/com/squareup/workflow/ui/compose/ViewFactories.kt b/core-compose/src/main/java/com/squareup/workflow/ui/compose/ViewFactories.kt index 5b052a4c..0dc5afbd 100644 --- a/core-compose/src/main/java/com/squareup/workflow/ui/compose/ViewFactories.kt +++ b/core-compose/src/main/java/com/squareup/workflow/ui/compose/ViewFactories.kt @@ -33,10 +33,14 @@ import com.squareup.workflow.ui.compose.ComposableViewStubWrapper.Update * To display a nested rendering from a [Composable view binding][bindCompose], use * [ViewEnvironment.showRendering]. * + * *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).* + * * @see ViewEnvironment.showRendering * @see com.squareup.workflow.ui.ViewRegistry.showRendering */ -// Bug: IR compiler pukes on ViewFactory here. +// TODO(https://issuetracker.google.com/issues/156527332) Should be ViewFactory @Composable internal fun ViewFactory.showRendering( rendering: RenderingT, viewEnvironment: ViewEnvironment, diff --git a/samples/hello-compose-binding/src/main/java/com/squareup/sample/hellocomposebinding/HelloBinding.kt b/samples/hello-compose-binding/src/main/java/com/squareup/sample/hellocomposebinding/HelloBinding.kt index d49017d0..1f06ed11 100644 --- a/samples/hello-compose-binding/src/main/java/com/squareup/sample/hellocomposebinding/HelloBinding.kt +++ b/samples/hello-compose-binding/src/main/java/com/squareup/sample/hellocomposebinding/HelloBinding.kt @@ -26,13 +26,9 @@ import androidx.ui.material.ripple.ripple import androidx.ui.tooling.preview.Preview import com.squareup.sample.hellocomposebinding.HelloWorkflow.Rendering import com.squareup.workflow.ui.compose.bindCompose +import com.squareup.workflow.ui.compose.tooling.preview val HelloBinding = bindCompose { rendering, _ -> - DrawHelloRendering(rendering) -} - -@Composable -private fun DrawHelloRendering(rendering: Rendering) { Clickable( modifier = Modifier.fillMaxSize() .ripple(bounded = true), @@ -44,5 +40,5 @@ private fun DrawHelloRendering(rendering: Rendering) { @Preview(heightDp = 150, showBackground = true) @Composable private fun DrawHelloRenderingPreview() { - DrawHelloRendering(Rendering("Hello!", onClick = {})) + HelloBinding.preview(Rendering("Hello!", onClick = {})) } diff --git a/samples/hello-compose-rendering/src/main/java/com/squareup/sample/hellocomposerendering/HelloRenderingWorkflow.kt b/samples/hello-compose-rendering/src/main/java/com/squareup/sample/hellocomposerendering/HelloRenderingWorkflow.kt index 1df824b1..1285fe1b 100644 --- a/samples/hello-compose-rendering/src/main/java/com/squareup/sample/hellocomposerendering/HelloRenderingWorkflow.kt +++ b/samples/hello-compose-rendering/src/main/java/com/squareup/sample/hellocomposerendering/HelloRenderingWorkflow.kt @@ -29,6 +29,7 @@ import com.squareup.sample.hellocomposerendering.HelloRenderingWorkflow.Toggle import com.squareup.workflow.Sink import com.squareup.workflow.compose.ComposeWorkflow import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.compose.tooling.preview /** * A [ComposeWorkflow] that is used by [HelloWorkflow] to render the screen. @@ -44,26 +45,19 @@ object HelloRenderingWorkflow : ComposeWorkflow() { outputSink: Sink, viewEnvironment: ViewEnvironment ) { - Hello(props, onClick = { outputSink.send(Toggle) }) - } -} - -@Composable private fun Hello( - text: String, - onClick: () -> Unit -) { - MaterialTheme { - Clickable( - onClick = onClick, - modifier = Modifier.ripple(bounded = true) - .fillMaxSize() - ) { - Text(text, modifier = Modifier.wrapContentSize(Alignment.Center)) + MaterialTheme { + Clickable( + onClick = { outputSink.send(Toggle) }, + modifier = Modifier.ripple(bounded = true) + .fillMaxSize() + ) { + Text(props, modifier = Modifier.wrapContentSize(Alignment.Center)) + } } } } @Preview(showBackground = true) @Composable private fun HelloRenderingWorkflowPreview() { - Hello("hello", onClick = {}) + HelloRenderingWorkflow.preview(props = "hello") } diff --git a/samples/nested-renderings/src/main/java/com/squareup/sample/nestedrenderings/LegacyRunner.kt b/samples/nested-renderings/src/main/java/com/squareup/sample/nestedrenderings/LegacyRunner.kt index 0643d232..c7c680f4 100644 --- a/samples/nested-renderings/src/main/java/com/squareup/sample/nestedrenderings/LegacyRunner.kt +++ b/samples/nested-renderings/src/main/java/com/squareup/sample/nestedrenderings/LegacyRunner.kt @@ -15,12 +15,17 @@ */ package com.squareup.sample.nestedrenderings +import androidx.compose.Composable +import androidx.ui.core.Modifier +import androidx.ui.layout.fillMaxSize +import androidx.ui.tooling.preview.Preview import com.squareup.sample.nestedrenderings.RecursiveWorkflow.LegacyRendering import com.squareup.sample.nestedrenderings.databinding.LegacyViewBinding import com.squareup.workflow.ui.LayoutRunner import com.squareup.workflow.ui.LayoutRunner.Companion.bind import com.squareup.workflow.ui.ViewEnvironment import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.compose.tooling.preview /** * A [LayoutRunner] that renders [LegacyRendering]s using the legacy view framework. @@ -38,3 +43,11 @@ class LegacyRunner(private val binding: LegacyViewBinding) : LayoutRunner { rendering, viewEnvironment - } } +@Preview +@Composable private fun RecursiveViewFactoryPreview() { + Providers(BackgroundColorAmbient provides Color.Green) { + RecursiveViewFactory.preview( + Rendering( + children = listOf( + "foo", + Rendering( + children = listOf("bar"), + onAddChildClicked = {}, onResetClicked = {} + ) + ), onAddChildClicked = {}, onResetClicked = {} + ), + placeholderModifier = Modifier.fillMaxSize() + ) + } +} + @Composable private fun Children( children: List, viewEnvironment: ViewEnvironment, diff --git a/settings.gradle.kts b/settings.gradle.kts index 8d99743b..ce3c6763 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ rootProject.name = "workflow-compose" include( + ":compose-tooling", ":core-compose", ":samples:hello-compose-binding", ":samples:hello-compose-rendering",