Call onFocusChanged when a focused composable is removed
Bug: 274798780
Test: ./gradlew compose:ui:ui:cC -P android.testInstrumentationRunnerArguments.package=androidx.compose.ui.focus.FocusEventCountTest
Test: ./gradlew compose:ui:ui:cC -P android.testInstrumentationRunnerArguments.package=androidx.compose.ui.focus.FocusTargetAttachDetachTest
(cherry picked from https://android-review.googlesource.com/q/commit:509a6f2e320979ab0357ccbd687ea7d2d6f4db6e)
Merged-In: I5fa68637d9d3e8f055cac37dde561e7fc44224c9
Change-Id: I5fa68637d9d3e8f055cac37dde561e7fc44224c9
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusEventCountTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusEventCountTest.kt
index 7c6cc56..7d49b02 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusEventCountTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusEventCountTest.kt
@@ -172,6 +172,34 @@
}
@Test
+ fun removingActiveComposable_onFocusEventIsCalledWithDefaultValue() {
+ // Arrange.
+ val focusStates = mutableListOf<FocusState>()
+ val focusRequester = FocusRequester()
+ var showBox by mutableStateOf(true)
+ rule.setFocusableContent {
+ if (showBox) {
+ Box(
+ modifier = Modifier
+ .onFocusEvent { focusStates.add(it) }
+ .focusRequester(focusRequester)
+ .focusTarget()
+ )
+ }
+ }
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ focusStates.clear()
+ }
+
+ // Act.
+ rule.runOnIdle { showBox = false }
+
+ // Assert.
+ rule.runOnIdle { assertThat(focusStates).isExactly(Inactive) }
+ }
+
+ @Test
fun removingActiveFocusNode_onFocusEventIsCalledTwice() {
// Arrange.
val focusStates = mutableListOf<FocusState>()
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTargetAttachDetachTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTargetAttachDetachTest.kt
index 10b7e0e..525d640 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTargetAttachDetachTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/focus/FocusTargetAttachDetachTest.kt
@@ -165,6 +165,67 @@
}
@Test
+ fun removedActiveFocusTargetAndFocusChanged_triggersOnFocusEvent() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ val focusRequester = FocusRequester()
+ var optionalModifiers by mutableStateOf(true)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .then(
+ if (optionalModifiers) {
+ Modifier
+ .onFocusEvent { focusState = it }
+ .focusTarget()
+ } else {
+ Modifier
+ }
+ )
+ )
+ }
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ assertThat(focusState.isFocused).isTrue()
+ }
+
+ // Act.
+ rule.runOnIdle { optionalModifiers = false }
+
+ // Assert.
+ rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
+ }
+
+ @Test
+ fun removedActiveComposable_doesNotTriggerOnFocusEvent() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ val focusRequester = FocusRequester()
+ var optionalBox by mutableStateOf(true)
+ rule.setFocusableContent {
+ if (optionalBox) {
+ Box(
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .onFocusEvent { focusState = it }
+ .focusTarget()
+ )
+ }
+ }
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ assertThat(focusState.isFocused).isTrue()
+ }
+
+ // Act.
+ rule.runOnIdle { optionalBox = false }
+
+ // Assert.
+ rule.runOnIdle { assertThat(focusState.isFocused).isFalse() }
+ }
+
+ @Test
fun removedCapturedFocusTarget_pointsToNextFocusTarget() {
// Arrange.
lateinit var focusState: FocusState
@@ -308,6 +369,45 @@
}
@Test
+ fun removedActiveComposable_clearsFocusFromAllParents() {
+ // Arrange.
+ lateinit var focusState: FocusState
+ lateinit var parentFocusState: FocusState
+ val focusRequester = FocusRequester()
+ var optionalBox by mutableStateOf(true)
+ rule.setFocusableContent {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { parentFocusState = it }
+ .focusTarget()
+ ) {
+ if (optionalBox) {
+ Box(
+ modifier = Modifier
+ .onFocusChanged { focusState = it }
+ .focusRequester(focusRequester)
+ .focusTarget()
+ )
+ }
+ }
+ }
+ rule.runOnIdle {
+ focusRequester.requestFocus()
+ assertThat(focusState.hasFocus).isTrue()
+ assertThat(parentFocusState.hasFocus).isTrue()
+ }
+
+ // Act.
+ rule.runOnIdle { optionalBox = false }
+
+ // Assert.
+ rule.runOnIdle {
+ assertThat(focusState.isFocused).isFalse()
+ assertThat(parentFocusState.isFocused).isFalse()
+ }
+ }
+
+ @Test
fun removedDeactivatedParentFocusTarget_pointsToNextFocusTarget() {
// Arrange.
lateinit var focusState: FocusState
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusInvalidationManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusInvalidationManager.kt
index 8421e1b..6217e96 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusInvalidationManager.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusInvalidationManager.kt
@@ -73,7 +73,13 @@
focusEventNodes.forEach { focusEventNode ->
// When focus nodes are removed, the corresponding focus events are scheduled for
// invalidation. If the focus event was also removed, we don't need to invalidate it.
- if (!focusEventNode.node.isAttached) return@forEach
+ // We call onFocusEvent with the default value, just to make it easier for the user,
+ // so that they don't have to keep track of whether they caused a focused item to be
+ // removed (Which would cause it to lose focus).
+ if (!focusEventNode.node.isAttached) {
+ focusEventNode.onFocusEvent(Inactive)
+ return@forEach
+ }
var requiresUpdate = true
var aggregatedNode = false