diff --git a/core-compose/api/core-compose.api b/core-compose/api/core-compose.api index a2a16ed7..594f3674 100644 --- a/core-compose/api/core-compose.api +++ b/core-compose/api/core-compose.api @@ -15,24 +15,6 @@ public final class com/squareup/workflow/ui/compose/ComposeViewFactory : com/squ public fun getType ()Lkotlin/reflect/KClass; } -public abstract interface class com/squareup/workflow/ui/compose/ComposeViewFactoryRoot { - public static final field Companion Lcom/squareup/workflow/ui/compose/ComposeViewFactoryRoot$Companion; - public static fun ()V - public abstract fun wrap (Lkotlin/jvm/functions/Function1;Landroidx/compose/Composer;)V -} - -public final class com/squareup/workflow/ui/compose/ComposeViewFactoryRoot$Companion : com/squareup/workflow/ui/ViewEnvironmentKey { - public static final fun ()V - public fun getDefault ()Lcom/squareup/workflow/ui/compose/ComposeViewFactoryRoot; - public synthetic fun getDefault ()Ljava/lang/Object; -} - -public final class com/squareup/workflow/ui/compose/ComposeViewFactoryRootKt { - public static final fun ()V - public static final fun ComposeViewFactoryRoot (Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow/ui/compose/ComposeViewFactoryRoot; - public static final fun withComposeViewFactoryRoot (Lcom/squareup/workflow/ui/ViewEnvironment;Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow/ui/ViewEnvironment; -} - public abstract class com/squareup/workflow/ui/compose/ComposeWorkflow : com/squareup/workflow/Workflow { public fun ()V public fun asStatefulWorkflow ()Lcom/squareup/workflow/StatefulWorkflow; @@ -43,6 +25,12 @@ public final class com/squareup/workflow/ui/compose/ComposeWorkflowKt { public static final fun composed (Lcom/squareup/workflow/Workflow$Companion;Lkotlin/jvm/functions/Function4;)Lcom/squareup/workflow/ui/compose/ComposeWorkflow; } +public final class com/squareup/workflow/ui/compose/CompositionRootKt { + public static final fun ()V + public static final fun withCompositionRoot (Lcom/squareup/workflow/ui/ViewEnvironment;Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow/ui/ViewEnvironment; + public static final fun withCompositionRoot (Lcom/squareup/workflow/ui/ViewRegistry;Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow/ui/ViewRegistry; +} + public final class com/squareup/workflow/ui/compose/ViewEnvironmentsKt { public static final fun WorkflowRendering (Ljava/lang/Object;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/ui/core/Modifier;Landroidx/compose/Composer;)V public static synthetic fun WorkflowRendering$default (Ljava/lang/Object;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/ui/core/Modifier;Landroidx/compose/Composer;ILjava/lang/Object;)V diff --git a/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ComposeViewFactoryTest.kt b/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ComposeViewFactoryTest.kt index af6a87c8..27f84d26 100644 --- a/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ComposeViewFactoryTest.kt +++ b/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ComposeViewFactoryTest.kt @@ -40,7 +40,7 @@ class ComposeViewFactoryTest { @Test fun wrapsFactoryWithRoot() { val wrapperText = mutableStateOf("one") val viewEnvironment = ViewEnvironment(ViewRegistry(TestFactory)) - .withComposeViewFactoryRoot { content -> + .withCompositionRoot { content -> Column { Text(wrapperText.value) content() diff --git a/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/SafeComposeViewFactoryRootTest.kt b/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/CompositionRootTest.kt similarity index 50% rename from core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/SafeComposeViewFactoryRootTest.kt rename to core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/CompositionRootTest.kt index 794caad8..a900a105 100644 --- a/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/SafeComposeViewFactoryRootTest.kt +++ b/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/CompositionRootTest.kt @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.squareup.workflow.ui.compose.internal +package com.squareup.workflow.ui.compose +import androidx.compose.FrameManager +import androidx.compose.mutableStateOf import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.ui.foundation.Text import androidx.ui.layout.Column @@ -23,28 +25,114 @@ import androidx.ui.test.assertIsDisplayed import androidx.ui.test.createComposeRule import androidx.ui.test.findByText import com.google.common.truth.Truth.assertThat -import com.squareup.workflow.ui.compose.ComposeViewFactoryRoot import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertFailsWith @RunWith(AndroidJUnit4::class) -class SafeComposeViewFactoryRootTest { +class CompositionRootTest { @Rule @JvmField val composeRule = createComposeRule() + @Test fun wrapWithRootIfNecessary_wrapsWhenNecessary() { + val root: CompositionRoot = { content -> + Column { + Text("one") + content() + } + } + + composeRule.setContent { + wrapWithRootIfNecessary(root) { + Text("two") + } + } + + findByText("one\ntwo").assertIsDisplayed() + } + + @Test fun wrapWithRootIfNecessary_onlyWrapsOnce() { + val root: CompositionRoot = { content -> + Column { + Text("one") + content() + } + } + + composeRule.setContent { + wrapWithRootIfNecessary(root) { + Text("two") + wrapWithRootIfNecessary(root) { + Text("three") + } + } + } + + findByText("one\ntwo\nthree").assertIsDisplayed() + } + + @Test fun wrapWithRootIfNecessary_seesUpdatesFromRootWrapper() { + val wrapperText = mutableStateOf("one") + val root: CompositionRoot = { content -> + Column { + Text(wrapperText.value) + content() + } + } + + composeRule.setContent { + wrapWithRootIfNecessary(root) { + Text("two") + } + } + + findByText("one\ntwo").assertIsDisplayed() + FrameManager.framed { + wrapperText.value = "ENO" + } + findByText("ENO\ntwo").assertIsDisplayed() + } + + @Test fun wrapWithRootIfNecessary_rewrapsWhenDifferentRoot() { + val root1: CompositionRoot = { content -> + Column { + Text("one") + content() + } + } + val root2: CompositionRoot = { content -> + Column { + Text("ENO") + content() + } + } + val viewEnvironment = mutableStateOf(root1) + + composeRule.setContent { + wrapWithRootIfNecessary(viewEnvironment.value) { + Text("two") + } + } + + findByText("one\ntwo").assertIsDisplayed() + FrameManager.framed { + viewEnvironment.value = root2 + } + findByText("ENO\ntwo").assertIsDisplayed() + } + @Test fun safeComposeViewFactoryRoot_wraps_content() { - val wrapped = ComposeViewFactoryRoot { content -> + val wrapped: CompositionRoot = { content -> Column { Text("Parent") content() } } - val safeRoot = SafeComposeViewFactoryRoot(wrapped) + val safeRoot = safeCompositionRoot(wrapped) composeRule.setContent { - safeRoot.wrap { + safeRoot { // Need an explicit semantics container, otherwise both Texts will be merged into a single // Semantics object with the text "Parent\nChild". Semantics(container = true) { @@ -58,12 +146,12 @@ class SafeComposeViewFactoryRootTest { } @Test fun safeComposeViewFactoryRoot_throws_whenChildrenNotInvoked() { - val wrapped = ComposeViewFactoryRoot { } - val safeRoot = SafeComposeViewFactoryRoot(wrapped) + val wrapped: CompositionRoot = { } + val safeRoot = safeCompositionRoot(wrapped) val error = assertFailsWith { composeRule.setContent { - safeRoot.wrap {} + safeRoot {} } } @@ -74,15 +162,15 @@ class SafeComposeViewFactoryRootTest { } @Test fun safeComposeViewFactoryRoot_throws_whenChildrenInvokedMultipleTimes() { - val wrapped = ComposeViewFactoryRoot { children -> + val wrapped: CompositionRoot = { children -> children() children() } - val safeRoot = SafeComposeViewFactoryRoot(wrapped) + val safeRoot = safeCompositionRoot(wrapped) val error = assertFailsWith { composeRule.setContent { - safeRoot.wrap { + safeRoot { Text("Hello") } } diff --git a/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ComposeViewFactoryRootTest.kt b/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ComposeViewFactoryRootTest.kt deleted file mode 100644 index 453c2545..00000000 --- a/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ComposeViewFactoryRootTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * 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.internal - -import androidx.compose.FrameManager -import androidx.compose.mutableStateOf -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.ui.foundation.Text -import androidx.ui.layout.Column -import androidx.ui.test.assertIsDisplayed -import androidx.ui.test.createComposeRule -import androidx.ui.test.findByText -import com.squareup.workflow.ui.ViewEnvironment -import com.squareup.workflow.ui.ViewRegistry -import com.squareup.workflow.ui.compose.withComposeViewFactoryRoot -import com.squareup.workflow.ui.compose.wrapWithRootIfNecessary -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ComposeViewFactoryRootTest { - - @Rule @JvmField val composeRule = createComposeRule() - - @Test fun wrapWithRootIfNecessary_handlesNoRoot() { - val viewEnvironment = ViewEnvironment(ViewRegistry()) - - composeRule.setContent { - wrapWithRootIfNecessary(viewEnvironment) { - Text("foo") - } - } - - findByText("foo").assertIsDisplayed() - } - - @Test fun wrapWithRootIfNecessary_wrapsWhenNecessary() { - val viewEnvironment = ViewEnvironment(ViewRegistry()) - .withComposeViewFactoryRoot { content -> - Column { - Text("one") - content() - } - } - - composeRule.setContent { - wrapWithRootIfNecessary(viewEnvironment) { - Text("two") - } - } - - findByText("one\ntwo").assertIsDisplayed() - } - - @Test fun wrapWithRootIfNecessary_onlyWrapsOnce() { - val viewEnvironment = ViewEnvironment(ViewRegistry()) - .withComposeViewFactoryRoot { content -> - Column { - Text("one") - content() - } - } - - composeRule.setContent { - wrapWithRootIfNecessary(viewEnvironment) { - Text("two") - wrapWithRootIfNecessary(viewEnvironment) { - Text("three") - } - } - } - - findByText("one\ntwo\nthree").assertIsDisplayed() - } - - @Test fun wrapWithRootIfNecessary_seesUpdatesFromRootWrapper() { - val wrapperText = mutableStateOf("one") - val viewEnvironment = ViewEnvironment(ViewRegistry()) - .withComposeViewFactoryRoot { content -> - Column { - Text(wrapperText.value) - content() - } - } - - composeRule.setContent { - wrapWithRootIfNecessary(viewEnvironment) { - Text("two") - } - } - - findByText("one\ntwo").assertIsDisplayed() - FrameManager.framed { - wrapperText.value = "ENO" - } - findByText("ENO\ntwo").assertIsDisplayed() - } - - @Test fun wrapWithRootIfNecessary_rewrapsWhenDifferentRoot() { - val viewEnvironment1 = ViewEnvironment(ViewRegistry()) - .withComposeViewFactoryRoot { content -> - Column { - Text("one") - content() - } - } - val viewEnvironment2 = ViewEnvironment(ViewRegistry()) - .withComposeViewFactoryRoot { content -> - Column { - Text("ENO") - content() - } - } - val viewEnvironment = mutableStateOf(viewEnvironment1) - - composeRule.setContent { - wrapWithRootIfNecessary(viewEnvironment.value) { - Text("two") - } - } - - findByText("one\ntwo").assertIsDisplayed() - FrameManager.framed { - viewEnvironment.value = viewEnvironment2 - } - findByText("ENO\ntwo").assertIsDisplayed() - } -} diff --git a/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ViewFactoriesTest.kt b/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ViewFactoriesTest.kt index 998e6d75..f88fe99c 100644 --- a/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ViewFactoriesTest.kt +++ b/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ViewFactoriesTest.kt @@ -25,7 +25,7 @@ import com.squareup.workflow.ui.ViewEnvironment import com.squareup.workflow.ui.ViewRegistry import com.squareup.workflow.ui.compose.composedViewFactory import com.squareup.workflow.ui.compose.WorkflowRendering -import com.squareup.workflow.ui.compose.withComposeViewFactoryRoot +import com.squareup.workflow.ui.compose.withCompositionRoot import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -37,7 +37,7 @@ class ViewFactoriesTest { @Test fun WorkflowRendering_wrapsFactoryWithRoot_whenAlreadyInComposition() { val viewEnvironment = ViewEnvironment(ViewRegistry(TestFactory)) - .withComposeViewFactoryRoot { content -> + .withCompositionRoot { content -> Column { Text("one") content() diff --git a/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt b/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt index bc8fc805..648ab3f8 100644 --- a/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt +++ b/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt @@ -73,12 +73,11 @@ import kotlin.reflect.KClass * * ## Initializing Compose context * - * Often all the [composedViewFactory]s in an app need to share some context – for example, certain - * ambients need to be provided, such as `MaterialTheme`. To configure this shared context, include - * a [ComposeViewFactoryRoot] in your top-level [ViewEnvironment] (e.g. by using - * [withComposeViewFactoryRoot]). The first time a [composedViewFactory] is used to show a - * rendering, its [showRendering] function will be wrapped with the [ComposeViewFactoryRoot]. - * See the documentation on [ComposeViewFactoryRoot] for more information. + * Often all the [composedViewFactory] factories in an app need to share some context – for example, + * certain ambients need to be provided, such as `MaterialTheme`. To configure this shared context, + * call [withCompositionRoot] on your top-level [ViewEnvironment]. The first time a + * [composedViewFactory] is used to show a rendering, its [showRendering] function will be wrapped + * with the [CompositionRoot]. See the documentation on [CompositionRoot] for more information. */ inline fun composedViewFactory( noinline showRendering: @Composable() ( @@ -90,7 +89,7 @@ inline fun composedViewFactory( @PublishedApi internal class ComposeViewFactory( override val type: KClass, - private val content: @Composable() (RenderingT, ViewEnvironment) -> Unit + internal val content: @Composable() (RenderingT, ViewEnvironment) -> Unit ) : ViewFactory { override fun buildView( @@ -132,22 +131,9 @@ internal class ComposeViewFactory( val parentComposition = initialViewEnvironment[ParentComposition] composeContainer.setOrSubcomposeContent(parentComposition.reference) { val (rendering, environment) = renderState.value!! - showRenderingWrappedWithRoot(rendering, environment) + content(rendering, environment) } return composeContainer } - - /** - * Invokes [content]. If this is the highest [ComposeViewFactory] in the tree, wraps with - * the [ComposeViewFactoryRoot] if present in the [ViewEnvironment]. - */ - @Composable internal fun showRenderingWrappedWithRoot( - rendering: RenderingT, - viewEnvironment: ViewEnvironment - ) { - wrapWithRootIfNecessary(viewEnvironment) { - content(rendering, viewEnvironment) - } - } } diff --git a/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactoryRoot.kt b/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactoryRoot.kt deleted file mode 100644 index 34e4667e..00000000 --- a/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactoryRoot.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 - -import androidx.compose.Composable -import androidx.compose.Direct -import androidx.compose.Providers -import androidx.compose.remember -import androidx.compose.staticAmbientOf -import com.squareup.workflow.ui.ViewEnvironment -import com.squareup.workflow.ui.ViewEnvironmentKey -import com.squareup.workflow.ui.compose.internal.SafeComposeViewFactoryRoot - -/** - * Used by [wrapWithRootIfNecessary] to ensure the [ComposeViewFactoryRoot] is only applied once. - */ -private val HasViewFactoryRootBeenApplied = staticAmbientOf { false } - -/** - * A `@Composable` function that is stored in a [ViewEnvironment] and will be used to wrap the first - * [composedViewFactory] composition. This can be used to setup any ambients that all - * [composedViewFactory]s need access to, such as ambients that specify the UI theme. - * - * This function will called once, to wrap the _highest-level_ [composedViewFactory] in the tree. - * However, ambients are propagated down to child [composedViewFactory] compositions, so any - * ambients provided here will be available in _all_ [composedViewFactory] compositions. - */ -interface ComposeViewFactoryRoot { - - @Composable fun wrap(content: @Composable() () -> Unit) - - companion object : ViewEnvironmentKey(ComposeViewFactoryRoot::class) { - override val default: ComposeViewFactoryRoot get() = NoopComposeViewFactoryRoot - } -} - -/** - * Adds a [ComposeViewFactoryRoot] to this [ViewEnvironment] that uses [wrapper] to wrap the first - * [composedViewFactory] composition. See [ComposeViewFactoryRoot] for more information. - */ -fun ViewEnvironment.withComposeViewFactoryRoot( - wrapper: @Composable() (content: @Composable() () -> Unit) -> Unit -): ViewEnvironment = this + (ComposeViewFactoryRoot to ComposeViewFactoryRoot(wrapper)) - -// This could be inline, but that makes the Compose compiler puke. -@Suppress("FunctionName") -fun ComposeViewFactoryRoot( - wrapper: @Composable() (content: @Composable() () -> Unit) -> Unit -): ComposeViewFactoryRoot = object : ComposeViewFactoryRoot { - @Composable override fun wrap(content: @Composable() () -> Unit) = wrapper(content) -} - -/** - * Adds [content] to the composition, ensuring that any [ComposeViewFactoryRoot] present in the - * [ViewEnvironment] has been applied. Will only apply the root at the highest occurrence of this - * function in the composition subtree. - */ -@Composable internal fun wrapWithRootIfNecessary( - viewEnvironment: ViewEnvironment, - content: @Composable() () -> Unit -) { - if (HasViewFactoryRootBeenApplied.current) { - // The only way this ambient can have the value true is if, somewhere above this point in the - // composition, the else case below was hit and wrapped us in the ambient. Since the root - // wrapper will have already been applied, we can just compose content directly. - content() - } else { - // If the ambient is false, this is the first time this function has appeared in the composition - // so far. We provide a true value for the ambient for everything below us, so any recursive - // calls to this function will hit the if case above and not re-apply the wrapper. - Providers(HasViewFactoryRootBeenApplied provides true) { - val decorator = viewEnvironment[ComposeViewFactoryRoot] - val safeDecorator = remember(decorator) { - SafeComposeViewFactoryRoot(decorator) - } - safeDecorator.wrap(content) - } - } -} - -private object NoopComposeViewFactoryRoot : ComposeViewFactoryRoot { - @Direct @Composable override fun wrap(content: @Composable() () -> Unit) { - content() - } -} diff --git a/core-compose/src/main/java/com/squareup/workflow/ui/compose/CompositionRoot.kt b/core-compose/src/main/java/com/squareup/workflow/ui/compose/CompositionRoot.kt new file mode 100644 index 00000000..b01dc6fa --- /dev/null +++ b/core-compose/src/main/java/com/squareup/workflow/ui/compose/CompositionRoot.kt @@ -0,0 +1,110 @@ +/* + * 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 + +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.PRIVATE +import androidx.compose.Composable +import androidx.compose.Providers +import androidx.compose.remember +import androidx.compose.staticAmbientOf +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.compose.internal.mapFactories +import kotlin.reflect.KClass + +/** + * Used by [wrapWithRootIfNecessary] to ensure the [CompositionRoot] is only applied once. + */ +private val HasViewFactoryRootBeenApplied = staticAmbientOf { false } + +/** + * A `@Composable` function that will be used to wrap the first (highest-level) + * [composedViewFactory] view factory in a composition. This can be used to setup any ambients that + * all [composedViewFactory] factories need access to, such as e.g. UI themes. + * + * This function will called once, to wrap the _highest-level_ [composedViewFactory] in the tree. + * However, ambients are propagated down to child [composedViewFactory] compositions, so any + * ambients provided here will be available in _all_ [composedViewFactory] compositions. + */ +typealias CompositionRoot = @Composable() (content: @Composable() () -> Unit) -> Unit + +/** + * Convenience function for applying a [CompositionRoot] to this [ViewEnvironment]'s [ViewRegistry]. + * See [ViewRegistry.withCompositionRoot]. + */ +fun ViewEnvironment.withCompositionRoot(root: CompositionRoot): ViewEnvironment = + this + (ViewRegistry to this[ViewRegistry].withCompositionRoot(root)) + +/** + * Returns a [ViewRegistry] that ensures that any [composedViewFactory] factories registered in this + * registry will be wrapped exactly once with a [CompositionRoot] wrapper. + * See [CompositionRoot] for more information. + */ +fun ViewRegistry.withCompositionRoot(root: CompositionRoot): ViewRegistry = + mapFactories { factory -> + if (factory !is ComposeViewFactory) return@mapFactories factory + + @Suppress("UNCHECKED_CAST") + ComposeViewFactory(factory.type as KClass) { rendering, environment -> + wrapWithRootIfNecessary(root) { + (factory as ComposeViewFactory).content(rendering, environment) + } + } + } + +/** + * 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. + */ +@VisibleForTesting(otherwise = PRIVATE) +@Composable internal fun wrapWithRootIfNecessary( + root: CompositionRoot, + content: @Composable() () -> Unit +) { + if (HasViewFactoryRootBeenApplied.current) { + // The only way this ambient can have the value true is if, somewhere above this point in the + // composition, the else case below was hit and wrapped us in the ambient. Since the root + // wrapper will have already been applied, we can just compose content directly. + content() + } else { + // If the ambient is false, this is the first time this function has appeared in the composition + // so far. We provide a true value for the ambient for everything below us, so any recursive + // calls to this function will hit the if case above and not re-apply the wrapper. + Providers(HasViewFactoryRootBeenApplied provides true) { + val safeRoot: CompositionRoot = remember(root) { safeCompositionRoot(root) } + safeRoot(content) + } + } +} + +/** + * [CompositionRoot] that asserts that the content method invokes its children parameter + * exactly once, and throws an [IllegalStateException] if not. + */ +internal fun safeCompositionRoot(delegate: CompositionRoot): CompositionRoot = { content -> + var childrenCalledCount = 0 + delegate { + childrenCalledCount++ + content() + } + check(childrenCalledCount == 1) { + "Expected ComposableDecorator to invoke children exactly once, " + + "but was invoked $childrenCalledCount times." + } +} diff --git a/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/SafeComposeViewFactoryRoot.kt b/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/SafeComposeViewFactoryRoot.kt deleted file mode 100644 index c2460be2..00000000 --- a/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/SafeComposeViewFactoryRoot.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.internal - -import androidx.compose.Composable -import com.squareup.workflow.ui.compose.ComposeViewFactoryRoot - -/** - * [ComposeViewFactoryRoot] that asserts that the [wrap] method invokes its children parameter - * exactly once, and throws an [IllegalStateException] if not. - */ -internal class SafeComposeViewFactoryRoot( - private val delegate: ComposeViewFactoryRoot -) : ComposeViewFactoryRoot { - - @Composable override fun wrap(content: @Composable() () -> Unit) { - var childrenCalledCount = 0 - delegate.wrap { - childrenCalledCount++ - content() - } - check(childrenCalledCount == 1) { - "Expected ComposableDecorator to invoke children exactly once, " + - "but was invoked $childrenCalledCount times." - } - } -} diff --git a/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt b/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt index dab4d885..4e1a7fee 100644 --- a/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt +++ b/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt @@ -57,7 +57,7 @@ import com.squareup.workflow.ui.showRendering // "Fast" path: If the child binding is also a Composable, we don't need to go through the // legacy view system and can just invoke the binding's composable function directly. if (viewFactory is ComposeViewFactory) { - viewFactory.showRenderingWrappedWithRoot(rendering, viewEnvironment) + viewFactory.content(rendering, viewEnvironment) return@Box } diff --git a/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewRegistries.kt b/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewRegistries.kt new file mode 100644 index 00000000..8bec77e0 --- /dev/null +++ b/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewRegistries.kt @@ -0,0 +1,42 @@ +/* + * 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.internal + +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.ViewRegistry +import kotlin.reflect.KClass + +/** + * Applies [transform] to each [ViewFactory] in this registry. Transformations are applied lazily, + * at the time of lookup via [ViewRegistry.getFactoryFor]. + */ +internal fun ViewRegistry.mapFactories( + transform: (ViewFactory<*>) -> ViewFactory<*> +): ViewRegistry = object : ViewRegistry { + override val keys: Set> get() = this@mapFactories.keys + + override fun getFactoryFor( + renderingType: KClass + ): ViewFactory { + val transformedFactory = transform(this@mapFactories.getFactoryFor(renderingType)) + check(transformedFactory.type == renderingType) { + "Expected transform to return a ViewFactory that is compatible with $renderingType, " + + "but got one with type ${transformedFactory.type}" + } + @Suppress("UNCHECKED_CAST") + return transformedFactory as ViewFactory + } +} diff --git a/samples/hello-compose-binding/src/main/java/com/squareup/sample/hellocomposebinding/HelloBindingActivity.kt b/samples/hello-compose-binding/src/main/java/com/squareup/sample/hellocomposebinding/HelloBindingActivity.kt index 47971e35..adcb5db4 100644 --- a/samples/hello-compose-binding/src/main/java/com/squareup/sample/hellocomposebinding/HelloBindingActivity.kt +++ b/samples/hello-compose-binding/src/main/java/com/squareup/sample/hellocomposebinding/HelloBindingActivity.kt @@ -22,11 +22,11 @@ import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener import com.squareup.workflow.ui.ViewEnvironment import com.squareup.workflow.ui.ViewRegistry import com.squareup.workflow.ui.WorkflowRunner -import com.squareup.workflow.ui.compose.withComposeViewFactoryRoot +import com.squareup.workflow.ui.compose.withCompositionRoot import com.squareup.workflow.ui.setContentWorkflow private val viewRegistry = ViewRegistry(HelloBinding) -private val containerHints = ViewEnvironment(viewRegistry).withComposeViewFactoryRoot { content -> +private val containerHints = ViewEnvironment(viewRegistry).withCompositionRoot { content -> MaterialTheme(content = content) } diff --git a/samples/nested-renderings/src/main/java/com/squareup/sample/nestedrenderings/NestedRenderingsActivity.kt b/samples/nested-renderings/src/main/java/com/squareup/sample/nestedrenderings/NestedRenderingsActivity.kt index bd4d021d..210d9df2 100644 --- a/samples/nested-renderings/src/main/java/com/squareup/sample/nestedrenderings/NestedRenderingsActivity.kt +++ b/samples/nested-renderings/src/main/java/com/squareup/sample/nestedrenderings/NestedRenderingsActivity.kt @@ -23,7 +23,7 @@ import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener import com.squareup.workflow.ui.ViewEnvironment import com.squareup.workflow.ui.ViewRegistry import com.squareup.workflow.ui.WorkflowRunner -import com.squareup.workflow.ui.compose.withComposeViewFactoryRoot +import com.squareup.workflow.ui.compose.withCompositionRoot import com.squareup.workflow.ui.setContentWorkflow private val viewRegistry = ViewRegistry( @@ -31,7 +31,7 @@ private val viewRegistry = ViewRegistry( LegacyRunner ) -private val viewEnvironment = ViewEnvironment(viewRegistry).withComposeViewFactoryRoot { content -> +private val viewEnvironment = ViewEnvironment(viewRegistry).withCompositionRoot { content -> Providers(BackgroundColorAmbient provides Color.Green, children = content) }