Skip to content

Commit 0746c67

Browse files
committed
Fix iOS text field focus transfer across scenes
1 parent d23558f commit 0746c67

3 files changed

Lines changed: 172 additions & 9 deletions

File tree

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/TextInputConnection.ios.kt

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -248,21 +248,27 @@ internal abstract class TextInputConnection(
248248
}
249249

250250
/**
251-
* Returns true if there is a focused view in the window hierarchy that is an external
252-
* text input — i.e. a native UITextField or UITextView inserted via interop, not one of
253-
* Compose's own input views.
251+
* Returns true if there is a focused view in the window hierarchy that is an external text
252+
* input — i.e. a native UITextField or UITextView inserted via interop, or a Compose text input
253+
* view owned by another independent scene.
254254
*
255255
* Used to distinguish the case where the user tapped a native interop text field (in which
256-
* case Compose focus should be released) from the case where focus simply moved to another
257-
* Compose text field (in which case Compose handles focus internally and no action is needed).
256+
* case Compose focus should be released) or another Compose scene's text field from the case
257+
* where focus simply moved inside the same focused views hierarchy (in which case Compose
258+
* handles focus internally and no action is needed).
258259
*/
259260
private fun hasFocusedExternalInputViewInWindowHierarchy(): Boolean {
260261
fun hasFocusedExternalInputView(view: UIView): Boolean {
261262
if (view.isFirstResponder) {
262-
return view !is NativeTextInputView &&
263-
view !is ComposeTextInputView &&
264-
view !is OverlayInputView &&
265-
view !is BackgroundInputView
263+
return if (view is NativeTextInputView ||
264+
view is ComposeTextInputView ||
265+
view is OverlayInputView ||
266+
view is BackgroundInputView
267+
) {
268+
focusedViewsList?.contains(view) == false
269+
} else {
270+
true
271+
}
266272
}
267273
return view.subviews.any { it is UIView && hasFocusedExternalInputView(it) }
268274
}

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/FocusedViewsList.ios.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ internal class FocusedViewsList {
7070
}
7171
}
7272

73+
fun contains(view: UIView): Boolean = rootList().containsInHierarchy(view)
74+
7375
/**
7476
* Dispose the child list, providing focus back to the parent list.
7577
*/
@@ -114,6 +116,12 @@ internal class FocusedViewsList {
114116
?: activeViews.lastOrNull()
115117
}
116118

119+
private fun containsInHierarchy(view: UIView): Boolean {
120+
return activeViews.contains(view) ||
121+
resignedViews.contains(view) ||
122+
children.any { it.containsInHierarchy(view) }
123+
}
124+
117125
private fun resignScheduledViews() {
118126
resignedViews.fastForEachReversed {
119127
it.resignFirstResponder()
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui.interaction
18+
19+
import androidx.compose.foundation.background
20+
import androidx.compose.foundation.border
21+
import androidx.compose.foundation.layout.Column
22+
import androidx.compose.foundation.layout.fillMaxSize
23+
import androidx.compose.foundation.layout.fillMaxWidth
24+
import androidx.compose.foundation.layout.height
25+
import androidx.compose.foundation.layout.padding
26+
import androidx.compose.foundation.text.BasicTextField
27+
import androidx.compose.runtime.Composable
28+
import androidx.compose.ui.ExperimentalComposeUiApi
29+
import androidx.compose.ui.Modifier
30+
import androidx.compose.ui.focus.onFocusChanged
31+
import androidx.compose.ui.geometry.Rect
32+
import androidx.compose.ui.graphics.Color
33+
import androidx.compose.ui.layout.boundsInWindow
34+
import androidx.compose.ui.layout.onGloballyPositioned
35+
import androidx.compose.ui.test.findFocusedUITextInput
36+
import androidx.compose.ui.test.runUIKitInstrumentedTest
37+
import androidx.compose.ui.test.utils.center
38+
import androidx.compose.ui.unit.DpRect
39+
import androidx.compose.ui.unit.dp
40+
import androidx.compose.ui.unit.toDpRect
41+
import androidx.compose.ui.viewinterop.UIKitInteropProperties
42+
import androidx.compose.ui.viewinterop.UIKitView
43+
import androidx.compose.ui.window.ComposeUIView
44+
import kotlin.test.Test
45+
import kotlin.test.assertEquals
46+
import kotlin.test.assertFalse
47+
import kotlin.test.assertTrue
48+
import kotlinx.cinterop.ExperimentalForeignApi
49+
import platform.CoreGraphics.CGRectMake
50+
import platform.UIKit.UITextInputProtocol
51+
import platform.UIKit.UIView
52+
53+
@OptIn(ExperimentalComposeUiApi::class, ExperimentalForeignApi::class)
54+
class NestedComposeTextFieldFocusTest {
55+
@Test
56+
fun focusMovesToTextFieldInNestedComposeUIView() = runUIKitInstrumentedTest {
57+
var outerFocused = false
58+
var nestedFocused = false
59+
var outerBounds: DpRect? = null
60+
var nestedBounds: DpRect? = null
61+
62+
setContent {
63+
Column(
64+
modifier = Modifier
65+
.fillMaxSize()
66+
.background(Color.White)
67+
.padding(24.dp)
68+
) {
69+
UIKitView(
70+
factory = {
71+
UIView().apply {
72+
val nestedComposeView = ComposeUIView(
73+
configure = { enforceStrictPlistSanityCheck = false }
74+
) {
75+
FocusReportingTextField(
76+
value = NestedFieldText,
77+
onFocusChanged = { nestedFocused = it },
78+
onBoundsChanged = { nestedBounds = it.toDpRect(density) }
79+
)
80+
}
81+
nestedComposeView.setFrame(CGRectMake(16.0, 16.0, 300.0, 80.0))
82+
addSubview(nestedComposeView)
83+
}
84+
},
85+
modifier = Modifier
86+
.fillMaxWidth()
87+
.height(120.dp),
88+
properties = UIKitInteropProperties(placedAsOverlay = true)
89+
)
90+
91+
FocusReportingTextField(
92+
value = OuterFieldText,
93+
onFocusChanged = { outerFocused = it },
94+
onBoundsChanged = { outerBounds = it.toDpRect(density) }
95+
)
96+
}
97+
}
98+
99+
waitUntil("Both text fields should be laid out") {
100+
outerBounds != null && nestedBounds != null
101+
}
102+
103+
tap(outerBounds!!.center())
104+
waitUntil("Outer text field should be focused after tap") {
105+
outerFocused && !nestedFocused
106+
}
107+
assertEquals(OuterFieldText, findFocusedUITextInput()?.text)
108+
109+
tap(nestedBounds!!.center())
110+
waitUntil("Nested text field should take focus and release the outer text field") {
111+
nestedFocused && !outerFocused
112+
}
113+
assertEquals(NestedFieldText, findFocusedUITextInput()?.text)
114+
115+
assertTrue(nestedFocused)
116+
assertFalse(outerFocused)
117+
}
118+
119+
@Composable
120+
private fun FocusReportingTextField(
121+
value: String,
122+
onFocusChanged: (Boolean) -> Unit,
123+
onBoundsChanged: (Rect) -> Unit
124+
) {
125+
BasicTextField(
126+
value = value,
127+
onValueChange = {},
128+
modifier = Modifier
129+
.fillMaxWidth()
130+
.height(64.dp)
131+
.padding(8.dp)
132+
.border(1.dp, Color.Black)
133+
.padding(8.dp)
134+
.onGloballyPositioned { onBoundsChanged(it.boundsInWindow()) }
135+
.onFocusChanged { onFocusChanged(it.isFocused) }
136+
)
137+
}
138+
139+
private val UITextInputProtocol.text: String?
140+
get() {
141+
val range = textRangeFromPosition(beginningOfDocument, endOfDocument) ?: return null
142+
return textInRange(range)
143+
}
144+
145+
private companion object {
146+
const val OuterFieldText = "outer field"
147+
const val NestedFieldText = "nested field"
148+
}
149+
}

0 commit comments

Comments
 (0)