Skip to content

Commit 698aa80

Browse files
committed
Uses DisposeOnViewTreeLifecycleDestroyed for ComposeView
Fixes a problem where removing a `ComposeView` from the view hierarchy and then reintroducing it breaks things. For example this happens when `DialogCollator` calls `dimiss()` / `show()` on `Dialog` windows to re-order them. But it could also happen with things like `RecyclerView`, or anything that might do interesting classic view management. This kind of issue is why `ViewTreeLifecycleOwner` was invented, and why we've worked so hard to keep it working.
1 parent 040ece4 commit 698aa80

File tree

2 files changed

+184
-0
lines changed

2 files changed

+184
-0
lines changed

workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.squareup.workflow1.ui.compose
33
import android.content.Context
44
import android.view.View
55
import android.view.ViewGroup
6+
import android.widget.FrameLayout
67
import androidx.activity.ComponentDialog
78
import androidx.compose.foundation.clickable
89
import androidx.compose.foundation.text.BasicText
@@ -30,10 +31,14 @@ import com.squareup.workflow1.ui.Compatible
3031
import com.squareup.workflow1.ui.NamedScreen
3132
import com.squareup.workflow1.ui.Screen
3233
import com.squareup.workflow1.ui.ScreenViewFactory
34+
import com.squareup.workflow1.ui.ScreenViewFactory.Companion.forWrapper
35+
import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromCode
3336
import com.squareup.workflow1.ui.ScreenViewHolder
3437
import com.squareup.workflow1.ui.ViewEnvironment
3538
import com.squareup.workflow1.ui.ViewRegistry
3639
import com.squareup.workflow1.ui.WorkflowUiExperimentalApi
40+
import com.squareup.workflow1.ui.WorkflowViewStub
41+
import com.squareup.workflow1.ui.Wrapper
3742
import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule
3843
import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule
3944
import com.squareup.workflow1.ui.internal.test.WorkflowUiTestActivity
@@ -651,6 +656,163 @@ internal class ComposeViewTreeIntegrationTest {
651656
.assertIsDisplayed()
652657
}
653658

659+
@Test fun composition_handles_overlay_reordering() {
660+
val composeA: Screen = VanillaComposeRendering(
661+
compatibilityKey = "0",
662+
) {
663+
var counter by rememberSaveable { mutableStateOf(0) }
664+
BasicText(
665+
"Counter: $counter",
666+
Modifier
667+
.clickable { counter++ }
668+
.testTag(CounterTag)
669+
)
670+
}
671+
672+
val composeB: Screen = VanillaComposeRendering(
673+
compatibilityKey = "1",
674+
) {
675+
var counter by rememberSaveable { mutableStateOf(0) }
676+
BasicText(
677+
"Counter2: $counter",
678+
Modifier
679+
.clickable { counter++ }
680+
.testTag(CounterTag2)
681+
)
682+
}
683+
684+
scenario.onActivity {
685+
it.setRendering(
686+
BodyAndOverlaysScreen(
687+
EmptyRendering,
688+
listOf(
689+
TestOverlay(composeA),
690+
TestOverlay(composeB),
691+
// When we move this to the front, both of the other previously-upstream-
692+
// now-downstream dialogs will be dismissed and re-shown.
693+
TestOverlay(EmptyRendering)
694+
)
695+
)
696+
)
697+
}
698+
699+
composeRule.onNodeWithTag(CounterTag)
700+
.assertTextEquals("Counter: 0")
701+
.performClick()
702+
.assertTextEquals("Counter: 1")
703+
704+
composeRule.onNodeWithTag(CounterTag2)
705+
.assertTextEquals("Counter2: 0")
706+
.performClick()
707+
.assertTextEquals("Counter2: 1")
708+
709+
// Reorder the overlays, dialogs will be dismissed and re-shown to preserve order.
710+
711+
scenario.onActivity {
712+
it.setRendering(
713+
BodyAndOverlaysScreen(
714+
EmptyRendering,
715+
listOf(
716+
TestOverlay(EmptyRendering),
717+
TestOverlay(composeB),
718+
TestOverlay(composeA),
719+
)
720+
)
721+
)
722+
}
723+
724+
// Are they still responsive?
725+
726+
composeRule.onNodeWithTag(CounterTag)
727+
.assertTextEquals("Counter: 1")
728+
.performClick()
729+
.assertTextEquals("Counter: 2")
730+
731+
composeRule.onNodeWithTag(CounterTag2)
732+
.assertTextEquals("Counter2: 1")
733+
.performClick()
734+
.assertTextEquals("Counter2: 2")
735+
}
736+
737+
738+
@Test fun composition_under_view_stub_handles_overlay_reordering() {
739+
val composeA: Screen = VanillaComposeRendering(
740+
compatibilityKey = "0",
741+
) {
742+
var counter by rememberSaveable { mutableStateOf(0) }
743+
BasicText(
744+
"Counter: $counter",
745+
Modifier
746+
.clickable { counter++ }
747+
.testTag(CounterTag)
748+
)
749+
}
750+
751+
val composeB: Screen = VanillaComposeRendering(
752+
compatibilityKey = "1",
753+
) {
754+
var counter by rememberSaveable { mutableStateOf(0) }
755+
BasicText(
756+
"Counter2: $counter",
757+
Modifier
758+
.clickable { counter++ }
759+
.testTag(CounterTag2)
760+
)
761+
}
762+
763+
scenario.onActivity {
764+
it.setRendering(
765+
BodyAndOverlaysScreen(
766+
EmptyRendering,
767+
listOf(
768+
TestOverlay(ViewStubWrapper(composeA)),
769+
TestOverlay(ViewStubWrapper(composeB)),
770+
// When we move this to the front, both of the other previously-upstream-
771+
// now-downstream dialogs will be dismissed and re-shown.
772+
TestOverlay(EmptyRendering)
773+
)
774+
)
775+
)
776+
}
777+
778+
composeRule.onNodeWithTag(CounterTag)
779+
.assertTextEquals("Counter: 0")
780+
.performClick()
781+
.assertTextEquals("Counter: 1")
782+
783+
composeRule.onNodeWithTag(CounterTag2)
784+
.assertTextEquals("Counter2: 0")
785+
.performClick()
786+
.assertTextEquals("Counter2: 1")
787+
788+
// Reorder the overlays, dialogs will be dismissed and re-shown to preserve order.
789+
790+
scenario.onActivity {
791+
it.setRendering(
792+
BodyAndOverlaysScreen(
793+
EmptyRendering,
794+
listOf(
795+
TestOverlay(EmptyRendering),
796+
TestOverlay(ViewStubWrapper(composeB)),
797+
TestOverlay(ViewStubWrapper(composeA)),
798+
)
799+
)
800+
)
801+
}
802+
803+
// Are they still responsive?
804+
805+
composeRule.onNodeWithTag(CounterTag)
806+
.assertTextEquals("Counter: 1")
807+
.performClick()
808+
.assertTextEquals("Counter: 2")
809+
810+
composeRule.onNodeWithTag(CounterTag2)
811+
.assertTextEquals("Counter2: 1")
812+
.performClick()
813+
.assertTextEquals("Counter2: 2")
814+
}
815+
654816
private fun WorkflowUiTestActivity.setBackstack(vararg backstack: Screen) {
655817
setRendering(
656818
BackStackScreen.fromList(listOf<AndroidScreen<*>>(EmptyRendering) + backstack.asList())
@@ -668,6 +830,26 @@ internal class ComposeViewTreeIntegrationTest {
668830
}
669831
}
670832

833+
data class ViewStubWrapper<C : Screen>(
834+
override val content: C
835+
) : Screen, Wrapper<Screen, C>, AndroidScreen<ViewStubWrapper<C>> {
836+
override fun <D : Screen> map(transform: (C) -> D) = ViewStubWrapper(transform(content))
837+
838+
override val viewFactory: ScreenViewFactory<ViewStubWrapper<C>> =
839+
fromCode { _, initialEnvironment, context, _ ->
840+
val stub = WorkflowViewStub(context)
841+
842+
FrameLayout(context)
843+
.apply {
844+
this.addView(stub)
845+
}.let {
846+
ScreenViewHolder(initialEnvironment, it) { r, e ->
847+
stub.show(r.content, e)
848+
}
849+
}
850+
}
851+
}
852+
671853
/**
672854
* This is our own custom lovingly handcrafted implementation that creates [ComposeView]
673855
* itself, bypassing [ScreenComposableFactory] entirely. Allows us to mess with alternative

workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.compose.runtime.CompositionLocalProvider
1010
import androidx.compose.runtime.remember
1111
import androidx.compose.ui.platform.ComposeView
1212
import androidx.compose.ui.platform.LocalLifecycleOwner
13+
import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
1314
import androidx.compose.ui.viewinterop.AndroidView
1415
import androidx.lifecycle.setViewTreeLifecycleOwner
1516
import com.squareup.workflow1.ui.Screen
@@ -125,6 +126,7 @@ public fun <ScreenT : Screen> ScreenComposableFactory<ScreenT>.asViewFactory():
125126
container: ViewGroup?
126127
): ScreenViewHolder<ScreenT> {
127128
val view = ComposeView(context)
129+
view.setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)
128130
return ScreenViewHolder(initialEnvironment, view) { newRendering, environment ->
129131

130132
// Update the state whenever a new rendering is emitted.

0 commit comments

Comments
 (0)