Skip to content
This repository was archived by the owner on Feb 5, 2021. It is now read-only.

Commit dc194c3

Browse files
Merge pull request #29 from square/zachklipp/inline-viewstub
Inline WorkflowViewStub logic in ViewFactory.showRendering to avoid unnecessary intermediate Views.
2 parents 240cbe7 + da4527c commit dc194c3

File tree

6 files changed

+121
-70
lines changed

6 files changed

+121
-70
lines changed

.buildscript/android-ui-tests.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ android {
22
defaultConfig {
33
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
44
}
5+
testOptions {
6+
animationsDisabled = true
7+
}
58
}
69

710
dependencies {

.github/workflows/kotlin.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,7 @@ jobs:
100100
fail-fast: false
101101
matrix:
102102
api-level:
103-
# Tests are failing on APIs <24.
104-
#- 21
105-
#- 23
103+
- 21
106104
- 24
107105
- 29
108106
steps:

core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import androidx.compose.mutableStateOf
2929
import com.squareup.workflow.ui.ViewEnvironment
3030
import com.squareup.workflow.ui.ViewFactory
3131
import com.squareup.workflow.ui.bindShowRendering
32-
import com.squareup.workflow.ui.compose.internal.setOrContinueContent
32+
import com.squareup.workflow.ui.compose.internal.ParentComposition
33+
import com.squareup.workflow.ui.compose.internal.setOrSubcomposeContent
3334
import kotlin.reflect.KClass
3435

3536
/**
@@ -111,23 +112,25 @@ internal class ComposeViewFactory<RenderingT : Any>(
111112
areEquivalent = StructurallyEqual
112113
)
113114

114-
// Models will throw if their properties are accessed when there is no frame open. Currently,
115-
// that will be the case if the model is accessed before any other Compose infrastructure has
116-
// ran, i.e. if this view factory is the first compose code to run in the app.
117-
// I believe that eventually there will be a global frame that will make this unnecessary.
118-
FrameManager.ensureStarted()
119-
120115
// Update the state whenever a new rendering is emitted.
121116
composeContainer.bindShowRendering(
122117
initialRendering,
123118
initialViewEnvironment
124119
) { rendering, environment ->
125120
// This lambda will be executed synchronously before bindShowRendering returns.
126-
renderState.value = Pair(rendering, environment)
121+
122+
// Models will throw if their properties are accessed when there is no frame open. Currently,
123+
// that will be the case if the model is accessed before any other Compose infrastructure has
124+
// run, i.e. if this view factory is the first compose code to run in the app.
125+
// I believe that eventually there will be a global frame that will make this unnecessary.
126+
FrameManager.framed {
127+
renderState.value = Pair(rendering, environment)
128+
}
127129
}
128130

129131
// Entry point to the world of Compose.
130-
composeContainer.setOrContinueContent(initialViewEnvironment) {
132+
val parentComposition = initialViewEnvironment[ParentComposition]
133+
composeContainer.setOrSubcomposeContent(parentComposition.reference) {
131134
val (rendering, environment) = renderState.value!!
132135
showRenderingWrappedWithRoot(rendering, environment)
133136
}

core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ParentComposition.kt

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,22 @@ import androidx.compose.Composable
2222
import androidx.compose.CompositionReference
2323
import androidx.compose.Recomposer
2424
import androidx.compose.compositionReference
25-
import androidx.compose.currentComposer
2625
import androidx.ui.core.setContent
2726
import com.squareup.workflow.ui.ViewEnvironment
2827
import com.squareup.workflow.ui.ViewEnvironmentKey
2928

3029
/**
31-
* Holds a [CompositionReference] and a [Recomposer] that can be used to [setContent] to create a
32-
* composition that is a child of another composition. Child compositions get ambients and other
33-
* compose context from their parent, which allows ambients provided around a [showRendering] call
34-
* to be read by nested [bindCompose] factories.
30+
* Holds a [CompositionReference] and that can be passed to [setOrSubcomposeContent] to create a
31+
* composition that is a child of another composition. Subcompositions get ambients and other
32+
* compose context from their parent, and propagate invalidations, which allows ambients provided
33+
* around a [showRendering] call to be read by nested Compose-based view factories.
3534
*
3635
* When [showRendering] is called, it will store an instance of this class in the [ViewEnvironment].
37-
* [ComposeViewFactory] will then pull the continuation out of the environment and use it to link
38-
* its composition to the outer one.
36+
* [ComposeViewFactory] pulls the reference out of the environment and uses it to link its
37+
* composition to the outer one.
3938
*/
40-
internal data class ParentComposition(
41-
val reference: CompositionReference? = null,
42-
val recomposer: Recomposer? = null
39+
internal class ParentComposition(
40+
var reference: CompositionReference? = null
4341
) {
4442
companion object : ViewEnvironmentKey<ParentComposition>(ParentComposition::class) {
4543
override val default: ParentComposition get() = ParentComposition()
@@ -50,31 +48,29 @@ internal data class ParentComposition(
5048
* Creates a [ParentComposition] from the current point in the composition and adds it to this
5149
* [ViewEnvironment].
5250
*/
53-
@Composable internal fun ViewEnvironment.withParentComposition(): ViewEnvironment {
54-
val compositionReference = ParentComposition(
55-
reference = compositionReference(),
56-
recomposer = currentComposer.recomposer
57-
)
51+
@Composable internal fun ViewEnvironment.withParentComposition(
52+
reference: CompositionReference = compositionReference()
53+
): ViewEnvironment {
54+
val compositionReference = ParentComposition(reference = reference)
5855
return this + (ParentComposition to compositionReference)
5956
}
6057

6158
/**
6259
* Starts composing [content] into this [ViewGroup].
6360
*
64-
* If there is a [ParentComposition] present in [initialViewEnvironment], it will start the
65-
* composition as a subcomposition of that continuation.
61+
* If [parentComposition] is not null, [content] will be installed as a _subcomposition_ of the
62+
* parent composition, meaning that it will propagate ambients and invalidation.
6663
*
6764
* This function corresponds to [withParentComposition].
6865
*/
69-
internal fun ViewGroup.setOrContinueContent(
70-
initialViewEnvironment: ViewEnvironment,
66+
internal fun ViewGroup.setOrSubcomposeContent(
67+
parentComposition: CompositionReference?,
7168
content: @Composable() () -> Unit
7269
) {
73-
val (compositionReference, recomposer) = initialViewEnvironment[ParentComposition]
74-
if (compositionReference != null && recomposer != null) {
70+
if (parentComposition != null) {
7571
// Somewhere above us in the workflow rendering tree, there's another bindCompose factory.
7672
// We need to link to its composition reference so we inherit its ambients.
77-
setContent(recomposer, compositionReference, content)
73+
setContent(Recomposer.current(), parentComposition, content)
7874
} else {
7975
// This is the first bindCompose factory in the rendering tree, so it won't be a child
8076
// composition.

core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,24 @@
1515
*/
1616
package com.squareup.workflow.ui.compose.internal
1717

18-
import android.content.Context
19-
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
20-
import android.widget.FrameLayout
18+
import android.view.View
19+
import android.view.ViewGroup
2120
import androidx.compose.Composable
21+
import androidx.compose.compositionReference
22+
import androidx.compose.onPreCommit
23+
import androidx.compose.remember
24+
import androidx.ui.core.AndroidOwner
25+
import androidx.ui.core.ContextAmbient
2226
import androidx.ui.core.Modifier
27+
import androidx.ui.core.OwnerAmbient
28+
import androidx.ui.core.Ref
2329
import androidx.ui.foundation.Box
30+
import androidx.ui.viewinterop.AndroidView
2431
import com.squareup.workflow.ui.ViewEnvironment
2532
import com.squareup.workflow.ui.ViewFactory
26-
import com.squareup.workflow.ui.WorkflowViewStub
33+
import com.squareup.workflow.ui.canShowRendering
2734
import com.squareup.workflow.ui.compose.ComposeViewFactory
28-
import com.squareup.workflow.ui.compose.internal.ComposableViewStubWrapper.Update
35+
import com.squareup.workflow.ui.showRendering
2936

3037
/**
3138
* Renders [rendering] into the composition using the `ViewRegistry` from the [ViewEnvironment] to
@@ -49,49 +56,73 @@ import com.squareup.workflow.ui.compose.internal.ComposableViewStubWrapper.Updat
4956
) {
5057
val viewFactory = this
5158
Box(modifier = modifier) {
52-
// Fast path: If the child binding is also a Composable, we don't need to go through the legacy
53-
// view system and can just invoke the binding's composable function directly.
59+
// "Fast" path: If the child binding is also a Composable, we don't need to go through the
60+
// legacy view system and can just invoke the binding's composable function directly.
5461
if (viewFactory is ComposeViewFactory) {
5562
viewFactory.showRenderingWrappedWithRoot(rendering, viewEnvironment)
56-
} else {
57-
// Plumb the current composition "context" through the ViewEnvironment so any nested
58-
// composable factories get access to any ambients currently in effect.
59-
// See setOrContinueContent().
60-
val newEnvironment = viewEnvironment.withParentComposition()
61-
62-
// IntelliJ currently complains very loudly about this function call, but it actually
63-
// compiles. The IDE tooling isn't currently able to recognize that the Compose compiler
64-
// accepts this code.
65-
ComposableViewStubWrapper(update = Update(rendering, newEnvironment))
63+
return@Box
6664
}
65+
66+
// "Slow" path: Create a legacy Android View to show the rendering, like WorkflowViewStub.
67+
ViewFactoryAndroidView(viewFactory, rendering, viewEnvironment)
6768
}
6869
}
6970

7071
/**
71-
* Wraps a [WorkflowViewStub] with an API that is more Compose-friendly.
72+
* This is effectively the logic of [com.squareup.workflow.ui.WorkflowViewStub], but translated
73+
* into Compose idioms. This approach has a few advantages:
74+
*
75+
* - Avoids extra custom views required to host `WorkflowViewStub` inside a Composition. Its trick
76+
* of replacing itself in its parent doesn't play nice with Compose.
77+
* - Allows us to pass the correct parent view for inflation (the root of the composition).
78+
* - Avoids `WorkflowViewStub` having to do its own lookup to find the correct [ViewFactory], since
79+
* we already have the correct one.
7280
*
73-
* In particular, Compose will only generate `Emittable`s for views with a single constructor
74-
* that takes a [Context].
81+
* Like `WorkflowViewStub`, this function uses the [viewFactory] to create and memoize a [View] to
82+
* display the [rendering], keeps it updated with the latest [rendering] and [viewEnvironment], and
83+
* adds it to the composition.
7584
*
76-
* See [this slack message](https://kotlinlang.slack.com/archives/CJLTWPH7S/p1576264533012000?thread_ts=1576262311.008800&cid=CJLTWPH7S).
85+
* This function also passes a [ParentComposition] down through the [ViewEnvironment] so that if the
86+
* child view further nests any `ComposableViewFactory`s, they will be correctly subcomposed.
7787
*/
78-
private class ComposableViewStubWrapper(context: Context) : FrameLayout(context) {
88+
@Composable private fun <R : Any> ViewFactoryAndroidView(
89+
viewFactory: ViewFactory<R>,
90+
rendering: R,
91+
viewEnvironment: ViewEnvironment
92+
) {
93+
val childView = remember { Ref<View>() }
7994

80-
data class Update(
81-
val rendering: Any,
82-
val viewEnvironment: ViewEnvironment
83-
)
95+
// Plumb the current composition through the ViewEnvironment so any nested composable factories
96+
// get access to any ambients currently in effect. See setOrSubcomposeContent().
97+
val parentComposition = remember { ParentComposition() }
98+
parentComposition.reference = compositionReference()
99+
val wrappedEnvironment = remember(viewEnvironment) {
100+
viewEnvironment + (ParentComposition to parentComposition)
101+
}
84102

85-
private val viewStub = WorkflowViewStub(context)
103+
// A view factory can decide to recreate its view at any time. This also covers the case where
104+
// the value of the viewFactory argument has changed, including to one with a different type.
105+
if (childView.value?.canShowRendering(rendering) != true) {
106+
// If we don't pass the parent Android View, the child will have the wrong LayoutParams.
107+
// OwnerAmbient is deprecated, but the only way to get the root view currently. I've filed
108+
// a feature request to expose this as first-class API, see
109+
// https://issuetracker.google.com/issues/156875705.
110+
@Suppress("DEPRECATION")
111+
val parentView = (OwnerAmbient.current as? AndroidOwner)?.view as? ViewGroup
86112

87-
init {
88-
addView(viewStub)
113+
childView.value = viewFactory.buildView(
114+
initialRendering = rendering,
115+
initialViewEnvironment = wrappedEnvironment,
116+
contextForNewView = ContextAmbient.current,
117+
container = parentView
118+
)
89119
}
90120

91-
// Compose turns this into a parameter when you invoke this class as a Composable.
92-
fun setUpdate(update: Update) {
93-
viewStub.update(update.rendering, update.viewEnvironment)
121+
// Invoke the ViewFactory's update logic whenever the view, the rendering, or the ViewEnvironment
122+
// change.
123+
onPreCommit(childView.value, rendering, wrappedEnvironment) {
124+
childView.value!!.showRendering(rendering, wrappedEnvironment)
94125
}
95126

96-
override fun getLayoutParams(): LayoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
127+
AndroidView(childView.value!!)
97128
}

samples/nested-renderings/src/androidTest/java/com/squareup/sample/nestedrenderings/NestedRenderingsTest.kt

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import androidx.ui.test.assertIsDisplayed
2424
import androidx.ui.test.doClick
2525
import androidx.ui.test.findAllByText
2626
import androidx.ui.test.findByText
27-
import androidx.ui.test.last
2827
import org.junit.Rule
2928
import org.junit.Test
3029
import org.junit.runner.RunWith
@@ -49,11 +48,32 @@ class NestedRenderingsTest {
4948
findAllByText(ADD_BUTTON_TEXT)
5049
.assertCountEquals(4)
5150

52-
findAllByText("Reset").last()
53-
.doClick()
51+
resetAll()
5452
findAllByText(ADD_BUTTON_TEXT).assertCountEquals(1)
5553
}
5654

55+
/**
56+
* We can't rely on the order of nodes returned by [findAllByText], and the contents of the
57+
* collection will change as we remove nodes, so we have to double-loop over all reset buttons and
58+
* click them all until there is only one left.
59+
*/
60+
private fun resetAll() {
61+
var foundNodes = Int.MAX_VALUE
62+
while (foundNodes > 1) {
63+
foundNodes = 0
64+
findAllByText("Reset").forEach {
65+
try {
66+
it.assertExists()
67+
} catch (e: AssertionError) {
68+
// No more reset buttons, we're done.
69+
return@forEach
70+
}
71+
foundNodes++
72+
it.doClick()
73+
}
74+
}
75+
}
76+
5777
private fun SemanticsNodeInteractionCollection.forEach(
5878
block: (SemanticsNodeInteraction) -> Unit
5979
) {

0 commit comments

Comments
 (0)