Synchronously clean up AndroidViewBinding fragments
When an AndroidViewBinding component that inflated
a fragment is removed from composition and receives its
onRelease call, the Fragment state should be
synchronously cleaned up so that no additional state
is left behind in cases of backward writes or the
hot reload cases used by Live Edit (which does not
wait between disposing a previous composition and
starting the new composition).
Relnote: "`AndroidViewBinding` now synchronously
removes `Fragment` instances inflated by including a
`FragmentContainerView` in your layout as part of its
`onRelease` by using `commitNow` (instead of the `commit`
it was using previously), thus fixing issues with
Live Edit's method with replacing the composition upon
changes."
Test: newly added tests pass
BUG: 296343270
Change-Id: I58fbffefb19ed896ffedc835185718a24a86949e
diff --git a/compose/ui/ui-viewbinding/samples/src/androidTest/AndroidManifest.xml b/compose/ui/ui-viewbinding/samples/src/androidTest/AndroidManifest.xml
index cc8347b..c201303d 100644
--- a/compose/ui/ui-viewbinding/samples/src/androidTest/AndroidManifest.xml
+++ b/compose/ui/ui-viewbinding/samples/src/androidTest/AndroidManifest.xml
@@ -19,5 +19,6 @@
<activity android:name="androidx.compose.ui.samples.InflatedFragmentActivity"/>
<activity android:name="androidx.compose.ui.samples.ChildInflatedFragmentActivity"/>
<activity android:name="androidx.compose.ui.samples.EmptyFragmentActivity"/>
+ <activity android:name="androidx.compose.ui.samples.ComposeInflatedFragmentActivity"/>
</application>
</manifest>
diff --git a/compose/ui/ui-viewbinding/samples/src/androidTest/java/androidx/compose/ui/samples/FragmentRemoveTest.kt b/compose/ui/ui-viewbinding/samples/src/androidTest/java/androidx/compose/ui/samples/FragmentRemoveTest.kt
index 4f82926..ec98ec8 100644
--- a/compose/ui/ui-viewbinding/samples/src/androidTest/java/androidx/compose/ui/samples/FragmentRemoveTest.kt
+++ b/compose/ui/ui-viewbinding/samples/src/androidTest/java/androidx/compose/ui/samples/FragmentRemoveTest.kt
@@ -16,17 +16,28 @@
package androidx.compose.ui.samples
+import android.os.Bundle
+import android.view.ViewGroup
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewbinding.samples.R
import androidx.compose.ui.viewbinding.samples.databinding.SampleEditTextLayoutBinding
import androidx.compose.ui.viewbinding.samples.databinding.TestFragmentLayoutBinding
import androidx.compose.ui.viewinterop.AndroidViewBinding
import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.Lifecycle
+import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
+import androidx.testutils.withActivity
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import org.junit.Rule
@@ -116,6 +127,97 @@
// State should be reset back to the default
assertThat(binding.editText.text.toString()).isEqualTo("Default")
}
+
+ @Test
+ fun testRemovalRemovesStateOnBackwardWrite() {
+ var showStateA by mutableStateOf(true)
+
+ rule.setContent {
+ if (showStateA) {
+ AndroidViewBinding(TestFragmentLayoutBinding::inflate)
+ } else {
+ SideEffect {
+ showStateA = true
+ }
+ }
+ }
+
+ var fragment = rule.activity.supportFragmentManager
+ .findFragmentById(R.id.fragment_container)
+ assertWithMessage("Fragment should be present when AndroidViewBinding is in the hierarchy")
+ .that(fragment)
+ .isNotNull()
+
+ var binding = SampleEditTextLayoutBinding.bind(fragment!!.requireView())
+ assertThat(binding.editText.text.toString()).isEqualTo("Default")
+
+ // Update the state to allow verifying the state is destroyed when the
+ // AndroidViewBinding is removed from composition
+ rule.runOnUiThread {
+ binding.editText.setText("Updated")
+ }
+
+ showStateA = false
+
+ rule.waitForIdle()
+
+ fragment = rule.activity.supportFragmentManager
+ .findFragmentById(R.id.fragment_container)
+ assertWithMessage("Fragment should be present when AndroidViewBinding is in the hierarchy")
+ .that(fragment)
+ .isNotNull()
+ binding = SampleEditTextLayoutBinding.bind(fragment!!.requireView())
+
+ // State should be reset back to the default
+ assertThat(binding.editText.text.toString()).isEqualTo("Default")
+ }
+
+ @Test
+ fun testRemovalRemovesStateOnCompositionDisposalAndRecreation() {
+ with(ActivityScenario.launch(ComposeInflatedFragmentActivity::class.java)) {
+ withActivity {
+ val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container)!!
+ assertThat(fragment.lifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED)
+ val binding = SampleEditTextLayoutBinding.bind(fragment.requireView())
+ assertThat(binding.editText.text.toString()).isEqualTo("Default")
+
+ // Update the state to make sure it gets saved and restored properly
+ binding.editText.setText("Updated")
+
+ // detach - attach to dispose composition and compose it again
+ val root = composeView!!.parent as ViewGroup
+ root.removeView(composeView)
+ root.addView(composeView)
+ }
+
+ withActivity {
+ val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container)
+ assertThat(fragment).isNotNull()
+ assertThat(fragment!!.lifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED)
+ val recreatedBinding = SampleEditTextLayoutBinding.bind(
+ fragment.requireView())
+ assertThat(recreatedBinding.editText.text.toString()).isEqualTo("Default")
+ }
+ }
+ }
}
class EmptyFragmentActivity : FragmentActivity()
+
+class ComposeInflatedFragmentActivity : FragmentActivity() {
+ var composeView: ComposeView? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ AndroidViewBinding(
+ TestFragmentLayoutBinding::inflate,
+ Modifier.requiredSize(50.dp),
+ )
+ }
+
+ composeView = window.decorView
+ .findViewById<ViewGroup>(android.R.id.content)
+ .getChildAt(0) as? ComposeView
+ }
+}
diff --git a/compose/ui/ui-viewbinding/src/main/java/androidx/compose/ui/viewinterop/AndroidViewBinding.kt b/compose/ui/ui-viewbinding/src/main/java/androidx/compose/ui/viewinterop/AndroidViewBinding.kt
index 1424583..5f2a288 100644
--- a/compose/ui/ui-viewbinding/src/main/java/androidx/compose/ui/viewinterop/AndroidViewBinding.kt
+++ b/compose/ui/ui-viewbinding/src/main/java/androidx/compose/ui/viewinterop/AndroidViewBinding.kt
@@ -31,7 +31,7 @@
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentContainerView
-import androidx.fragment.app.commit
+import androidx.fragment.app.commitNow
import androidx.fragment.app.findFragment
import androidx.viewbinding.ViewBinding
@@ -195,7 +195,7 @@
if (existingFragment != null && !fragmentManager.isStateSaved) {
// If the state isn't saved, that means that some state change
// has removed this Composable from the hierarchy
- fragmentManager.commit {
+ fragmentManager.commitNow {
remove(existingFragment)
}
}