From 6de0aed8d3decba7dea041007d20d7828f9dd676 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 8 Jul 2026 15:20:44 +0800 Subject: [PATCH 1/2] Fix iOS text field focus transfer across scenes --- .../ui/text/input/TextInputConnection.ios.kt | 24 +-- .../compose/ui/window/FocusedViewsList.ios.kt | 8 + .../NestedComposeTextFieldFocusTest.kt | 140 ++++++++++++++++++ 3 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/TextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/TextInputConnection.ios.kt index b558cd47ce683..474735ed63ab6 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/TextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/TextInputConnection.ios.kt @@ -248,21 +248,27 @@ internal abstract class TextInputConnection( } /** - * Returns true if there is a focused view in the window hierarchy that is an external - * text input — i.e. a native UITextField or UITextView inserted via interop, not one of - * Compose's own input views. + * Returns true if there is a focused view in the window hierarchy that is an external text + * input — i.e. a native UITextField or UITextView inserted via interop, or a Compose text input + * view owned by another independent scene. * * Used to distinguish the case where the user tapped a native interop text field (in which - * case Compose focus should be released) from the case where focus simply moved to another - * Compose text field (in which case Compose handles focus internally and no action is needed). + * case Compose focus should be released) or another Compose scene's text field from the case + * where focus simply moved inside the same focused views hierarchy (in which case Compose + * handles focus internally and no action is needed). */ private fun hasFocusedExternalInputViewInWindowHierarchy(): Boolean { fun hasFocusedExternalInputView(view: UIView): Boolean { if (view.isFirstResponder) { - return view !is NativeTextInputView && - view !is ComposeTextInputView && - view !is OverlayInputView && - view !is BackgroundInputView + return if (view is NativeTextInputView || + view is ComposeTextInputView || + view is OverlayInputView || + view is BackgroundInputView + ) { + focusedViewsList?.contains(view) == false + } else { + true + } } return view.subviews.any { it is UIView && hasFocusedExternalInputView(it) } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/FocusedViewsList.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/FocusedViewsList.ios.kt index 8f286041f87c1..23f2189259366 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/FocusedViewsList.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/FocusedViewsList.ios.kt @@ -70,6 +70,8 @@ internal class FocusedViewsList { } } + fun contains(view: UIView): Boolean = rootList().containsInHierarchy(view) + /** * Dispose the child list, providing focus back to the parent list. */ @@ -114,6 +116,12 @@ internal class FocusedViewsList { ?: activeViews.lastOrNull() } + private fun containsInHierarchy(view: UIView): Boolean { + return activeViews.contains(view) || + resignedViews.contains(view) || + children.any { it.containsInHierarchy(view) } + } + private fun resignScheduledViews() { resignedViews.fastForEachReversed { it.resignFirstResponder() diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt new file mode 100644 index 0000000000000..144d3c2db9164 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * 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 androidx.compose.ui.interaction + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.findFocusedUITextInput +import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.UIKitInteropProperties +import androidx.compose.ui.viewinterop.UIKitView +import androidx.compose.ui.window.ComposeUIView +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.cinterop.ExperimentalForeignApi +import platform.CoreGraphics.CGRectMake +import platform.UIKit.UITextInputProtocol +import platform.UIKit.UIView + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalForeignApi::class) +class NestedComposeTextFieldFocusTest { + @Test + fun focusMovesToTextFieldInNestedComposeUIView() = runUIKitInstrumentedTest { + var outerFocused = false + var nestedFocused = false + + setContent { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(24.dp) + ) { + UIKitView( + factory = { + UIView().apply { + val nestedComposeView = ComposeUIView( + configure = { enforceStrictPlistSanityCheck = false } + ) { + FocusReportingTextField( + value = NestedFieldText, + onFocusChanged = { nestedFocused = it } + ) + } + nestedComposeView.setFrame(CGRectMake(16.0, 16.0, 300.0, 80.0)) + addSubview(nestedComposeView) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .testTag(NestedFieldHostTag), + properties = UIKitInteropProperties(placedAsOverlay = true) + ) + + FocusReportingTextField( + modifier = Modifier.testTag(OuterFieldTag), + value = OuterFieldText, + onFocusChanged = { outerFocused = it } + ) + } + } + + findNodeWithTag(OuterFieldTag).tap() + waitUntil("Outer text field should be focused after tap") { + outerFocused && !nestedFocused + } + assertEquals(OuterFieldText, findFocusedUITextInput()?.text) + + findNodeWithTag(NestedFieldHostTag).tap() + waitUntil("Nested text field should take focus and release the outer text field") { + nestedFocused && !outerFocused + } + assertEquals(NestedFieldText, findFocusedUITextInput()?.text) + + assertTrue(nestedFocused) + assertFalse(outerFocused) + } + + @Composable + private fun FocusReportingTextField( + modifier: Modifier = Modifier, + value: String, + onFocusChanged: (Boolean) -> Unit + ) { + BasicTextField( + value = value, + onValueChange = {}, + modifier = modifier + .fillMaxWidth() + .height(64.dp) + .padding(8.dp) + .border(1.dp, Color.Black) + .padding(8.dp) + .onFocusChanged { onFocusChanged(it.isFocused) } + ) + } + + private val UITextInputProtocol.text: String? + get() { + val range = textRangeFromPosition(beginningOfDocument, endOfDocument) ?: return null + return textInRange(range) + } + + private companion object { + const val OuterFieldTag = "OuterField" + const val NestedFieldHostTag = "NestedFieldHost" + const val OuterFieldText = "outer field" + const val NestedFieldText = "nested field" + } +} From ec0d0884f9003ab38eb2dd205bedbabf0ca382ea Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 10 Jul 2026 11:54:39 +0800 Subject: [PATCH 2/2] Fix nested Compose text field focus test on iPad --- .../NestedComposeTextFieldFocusTest.kt | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt index 144d3c2db9164..bd68aa58f590e 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt @@ -42,9 +42,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlinx.cinterop.ExperimentalForeignApi -import platform.CoreGraphics.CGRectMake import platform.UIKit.UITextInputProtocol -import platform.UIKit.UIView @OptIn(ExperimentalComposeUiApi::class, ExperimentalForeignApi::class) class NestedComposeTextFieldFocusTest { @@ -62,22 +60,18 @@ class NestedComposeTextFieldFocusTest { ) { UIKitView( factory = { - UIView().apply { - val nestedComposeView = ComposeUIView( - configure = { enforceStrictPlistSanityCheck = false } - ) { - FocusReportingTextField( - value = NestedFieldText, - onFocusChanged = { nestedFocused = it } - ) - } - nestedComposeView.setFrame(CGRectMake(16.0, 16.0, 300.0, 80.0)) - addSubview(nestedComposeView) + ComposeUIView( + configure = { enforceStrictPlistSanityCheck = false } + ) { + FocusReportingTextField( + value = NestedFieldText, + onFocusChanged = { nestedFocused = it } + ) } }, modifier = Modifier .fillMaxWidth() - .height(120.dp) + .height(80.dp) .testTag(NestedFieldHostTag), properties = UIKitInteropProperties(placedAsOverlay = true) )