Skip to content

Commit 0732244

Browse files
authored
Refactor text fields focus order (#2240)
Refactor the list of focused views to only remove dismissed views, rather than all of the following focused views. Fixes https://youtrack.jetbrains.com/issue/CMP-8300/iOS-Not-showing-keyboard-in-Dialog-when-parent-UI-changes ## Release Notes ### Fixes - iOS - Fixes the appearance of the keyboard when a pop-up or dialog on the background is dismissed.
1 parent 37e6ca3 commit 0732244

File tree

8 files changed

+191
-35
lines changed

8 files changed

+191
-35
lines changed

compose/ui/ui/api/ui.klib.api

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5063,7 +5063,7 @@ final val androidx.compose.ui.window/androidx_compose_ui_window_ComposeView$stab
50635063
final val androidx.compose.ui.window/androidx_compose_ui_window_DisplayLinkListener$stableprop // androidx.compose.ui.window/androidx_compose_ui_window_DisplayLinkListener$stableprop|#static{}androidx_compose_ui_window_DisplayLinkListener$stableprop[0]
50645064

50655065
// Targets: [ios]
5066-
final val androidx.compose.ui.window/androidx_compose_ui_window_FocusStack$stableprop // androidx.compose.ui.window/androidx_compose_ui_window_FocusStack$stableprop|#static{}androidx_compose_ui_window_FocusStack$stableprop[0]
5066+
final val androidx.compose.ui.window/androidx_compose_ui_window_FocusedViewsList$stableprop // androidx.compose.ui.window/androidx_compose_ui_window_FocusedViewsList$stableprop|#static{}androidx_compose_ui_window_FocusedViewsList$stableprop[0]
50675067

50685068
// Targets: [ios]
50695069
final val androidx.compose.ui.window/androidx_compose_ui_window_InflightCommandBuffers$stableprop // androidx.compose.ui.window/androidx_compose_ui_window_InflightCommandBuffers$stableprop|#static{}androidx_compose_ui_window_InflightCommandBuffers$stableprop[0]
@@ -5258,7 +5258,7 @@ final fun androidx.compose.ui.window/androidx_compose_ui_window_ComposeView$stab
52585258
final fun androidx.compose.ui.window/androidx_compose_ui_window_DisplayLinkListener$stableprop_getter(): kotlin/Int // androidx.compose.ui.window/androidx_compose_ui_window_DisplayLinkListener$stableprop_getter|androidx_compose_ui_window_DisplayLinkListener$stableprop_getter(){}[0]
52595259

52605260
// Targets: [ios]
5261-
final fun androidx.compose.ui.window/androidx_compose_ui_window_FocusStack$stableprop_getter(): kotlin/Int // androidx.compose.ui.window/androidx_compose_ui_window_FocusStack$stableprop_getter|androidx_compose_ui_window_FocusStack$stableprop_getter(){}[0]
5261+
final fun androidx.compose.ui.window/androidx_compose_ui_window_FocusedViewsList$stableprop_getter(): kotlin/Int // androidx.compose.ui.window/androidx_compose_ui_window_FocusedViewsList$stableprop_getter|androidx_compose_ui_window_FocusedViewsList$stableprop_getter(){}[0]
52625262

52635263
// Targets: [ios]
52645264
final fun androidx.compose.ui.window/androidx_compose_ui_window_InflightCommandBuffers$stableprop_getter(): kotlin/Int // androidx.compose.ui.window/androidx_compose_ui_window_InflightCommandBuffers$stableprop_getter|androidx_compose_ui_window_InflightCommandBuffers$stableprop_getter(){}[0]

compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ class ComposeSceneMediatorTest {
8282
private fun makeMediator(): ComposeSceneMediator {
8383
val mediator = ComposeSceneMediator(
8484
onFocusBehavior = OnFocusBehavior.DoNothing,
85-
focusStack = null,
85+
focusedViewsList = null,
8686
windowContext = PlatformWindowContext(),
8787
coroutineContext = Dispatchers.Main,
8888
redrawer = MetalRedrawer(
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright 2025 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.material.TextField
20+
import androidx.compose.runtime.LaunchedEffect
21+
import androidx.compose.runtime.mutableStateOf
22+
import androidx.compose.ui.Modifier
23+
import androidx.compose.ui.focus.FocusRequester
24+
import androidx.compose.ui.focus.focusRequester
25+
import androidx.compose.ui.test.UIKitInstrumentedTest
26+
import androidx.compose.ui.test.runUIKitInstrumentedTest
27+
import androidx.compose.ui.window.Dialog
28+
import kotlin.test.Test
29+
import kotlin.test.assertEquals
30+
import kotlin.test.assertNull
31+
import platform.UIKit.UITextInputProtocol
32+
import platform.UIKit.UIView
33+
34+
class TextFieldFocusOrderTest {
35+
@Test
36+
fun testModalTextFieldsRefocus() = runUIKitInstrumentedTest {
37+
val showDialog1 = mutableStateOf(false)
38+
val showDialog2 = mutableStateOf(false)
39+
val focusRequester0 = FocusRequester()
40+
val focusRequester1 = FocusRequester()
41+
val focusRequester2 = FocusRequester()
42+
43+
setContent {
44+
TextField("Text 0", {}, modifier = Modifier.focusRequester(focusRequester0))
45+
LaunchedEffect(Unit) {
46+
focusRequester0.requestFocus()
47+
}
48+
49+
if (showDialog1.value) {
50+
Dialog({}) {
51+
TextField("Text 1", {}, modifier = Modifier.focusRequester(focusRequester1))
52+
}
53+
LaunchedEffect(Unit) {
54+
focusRequester1.requestFocus()
55+
}
56+
}
57+
58+
if (showDialog2.value) {
59+
Dialog({}) {
60+
TextField("Text 2", {}, modifier = Modifier.focusRequester(focusRequester2))
61+
}
62+
LaunchedEffect(Unit) {
63+
focusRequester2.requestFocus()
64+
}
65+
}
66+
}
67+
68+
assertEquals("Text 0", findFocusedUITextInput()?.text)
69+
70+
showDialog1.value = true
71+
waitForIdle()
72+
assertEquals("Text 1", findFocusedUITextInput()?.text)
73+
74+
showDialog2.value = true
75+
waitForIdle()
76+
assertEquals("Text 2", findFocusedUITextInput()?.text)
77+
78+
showDialog2.value = false
79+
waitForIdle()
80+
assertEquals("Text 1", findFocusedUITextInput()?.text)
81+
82+
showDialog1.value = false
83+
waitForIdle()
84+
assertEquals("Text 0", findFocusedUITextInput()?.text)
85+
}
86+
87+
@Test
88+
fun testModalTextFieldsRefocusWhenParentDismissed() = runUIKitInstrumentedTest {
89+
val showTextField = mutableStateOf(true)
90+
val showDialog1 = mutableStateOf(true)
91+
val showDialog2 = mutableStateOf(true)
92+
val focusRequester0 = FocusRequester()
93+
val focusRequester1 = FocusRequester()
94+
val focusRequester2 = FocusRequester()
95+
96+
setContent {
97+
if (showTextField.value) {
98+
TextField("Text 0", {}, modifier = Modifier.focusRequester(focusRequester0))
99+
LaunchedEffect(Unit) {
100+
focusRequester0.requestFocus()
101+
}
102+
}
103+
104+
if (showDialog1.value) {
105+
Dialog({}) {
106+
TextField("Text 1", {}, modifier = Modifier.focusRequester(focusRequester1))
107+
}
108+
LaunchedEffect(Unit) {
109+
focusRequester1.requestFocus()
110+
}
111+
}
112+
113+
if (showDialog2.value) {
114+
Dialog({}) {
115+
TextField("Text 2", {}, modifier = Modifier.focusRequester(focusRequester2))
116+
}
117+
LaunchedEffect(Unit) {
118+
focusRequester2.requestFocus()
119+
}
120+
}
121+
}
122+
123+
assertEquals("Text 2", findFocusedUITextInput()?.text)
124+
125+
showTextField.value = false
126+
waitForIdle()
127+
assertEquals("Text 2", findFocusedUITextInput()?.text)
128+
129+
showDialog1.value = false
130+
waitForIdle()
131+
assertEquals("Text 2", findFocusedUITextInput()?.text)
132+
133+
showDialog2.value = false
134+
waitForIdle()
135+
assertNull(findFocusedUITextInput())
136+
}
137+
138+
private val UITextInputProtocol.text: String? get() {
139+
val range = textRangeFromPosition(beginningOfDocument, endOfDocument) ?: return null
140+
return textInRange(range)
141+
}
142+
143+
private fun UIKitInstrumentedTest.findFocusedUITextInput(): UITextInputProtocol? {
144+
val window = hostingViewController.view.window ?: return null
145+
146+
fun findFirstResponder(view: UIView): UIView? {
147+
if (view.isFirstResponder) {
148+
return view
149+
}
150+
view.subviews.forEach {
151+
findFirstResponder(it as UIView)?.let { return it }
152+
}
153+
return null
154+
}
155+
156+
return findFirstResponder(view = window) as? UITextInputProtocol
157+
}
158+
}

compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import androidx.compose.ui.unit.asCGRect
4444
import androidx.compose.ui.unit.asDpOffset
4545
import androidx.compose.ui.unit.toDpRect
4646
import androidx.compose.ui.unit.toOffset
47-
import androidx.compose.ui.window.FocusStack
47+
import androidx.compose.ui.window.FocusedViewsList
4848
import androidx.compose.ui.window.IntermediateTextInputUIView
4949
import kotlin.math.absoluteValue
5050
import kotlin.math.min
@@ -68,7 +68,7 @@ internal class UIKitTextInputService(
6868
private val updateView: () -> Unit,
6969
private val view: UIView,
7070
private val viewConfiguration: ViewConfiguration,
71-
private val focusStack: FocusStack?,
71+
private val focusedViewsList: FocusedViewsList?,
7272
private var onInputStarted: () -> Unit,
7373
/**
7474
* Callback to handle keyboard presses. The parameter is a [Set] of [UIPress] objects.
@@ -167,13 +167,13 @@ internal class UIKitTextInputService(
167167

168168
override fun showSoftwareKeyboard() {
169169
textUIView?.let {
170-
focusStack?.pushAndFocus(it)
170+
focusedViewsList?.addAndFocus(it)
171171
}
172172
}
173173

174174
override fun hideSoftwareKeyboard() {
175175
textUIView?.let {
176-
focusStack?.popUntilNext(it, delayMillis = CLEAR_FOCUS_DELAY)
176+
focusedViewsList?.remove(it, delayMillis = CLEAR_FOCUS_DELAY)
177177
}
178178
}
179179

compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import androidx.compose.ui.viewinterop.UIKitInteropTransaction
5656
import androidx.compose.ui.window.ApplicationActiveStateListener
5757
import androidx.compose.ui.window.ComposeView
5858
import androidx.compose.ui.window.DisplayLinkListener
59-
import androidx.compose.ui.window.FocusStack
59+
import androidx.compose.ui.window.FocusedViewsList
6060
import androidx.compose.ui.window.MetalRedrawer
6161
import androidx.compose.ui.window.MetalView
6262
import androidx.compose.ui.window.ViewControllerLifecycleDelegate
@@ -138,7 +138,7 @@ internal class ComposeHostingViewController(
138138
)
139139
private val systemThemeState: MutableState<SystemTheme> = mutableStateOf(SystemTheme.Unknown)
140140

141-
var focusStack: FocusStack? = FocusStack()
141+
var focusedViewsList: FocusedViewsList? = FocusedViewsList()
142142

143143
/*
144144
* On iOS >= 13.0 interfaceOrientation will be deduced from [UIWindowScene] of [UIWindow]
@@ -306,7 +306,7 @@ internal class ComposeHostingViewController(
306306

307307
mediator = ComposeSceneMediator(
308308
onFocusBehavior = configuration.onFocusBehavior,
309-
focusStack = focusStack,
309+
focusedViewsList = focusedViewsList,
310310
windowContext = windowContext,
311311
coroutineContext = composeCoroutineContext,
312312
redrawer = metalView.redrawer,
@@ -466,7 +466,7 @@ internal class ComposeHostingViewController(
466466
initLayoutDirection = layoutDirection,
467467
onFocusBehavior = configuration.onFocusBehavior,
468468
onAccessibilityChanged = ::onAccessibilityChanged,
469-
focusStack = if (focusable) focusStack else null,
469+
focusedViewsList = if (focusable) focusedViewsList else null,
470470
windowContext = windowContext,
471471
compositionContext = compositionContext,
472472
coroutineContext = composeCoroutineContext,

compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ import androidx.compose.ui.viewinterop.UIKitInteropContainer
8484
import androidx.compose.ui.viewinterop.UIKitInteropTransaction
8585
import androidx.compose.ui.window.ApplicationForegroundStateListener
8686
import androidx.compose.ui.window.ComposeSceneKeyboardOffsetManager
87-
import androidx.compose.ui.window.FocusStack
87+
import androidx.compose.ui.window.FocusedViewsList
8888
import androidx.compose.ui.window.KeyboardVisibilityListener
8989
import androidx.compose.ui.window.MetalRedrawer
9090
import androidx.compose.ui.window.TouchesEventKind
@@ -184,7 +184,7 @@ private class SemanticsOwnerListenerImpl(
184184

185185
internal class ComposeSceneMediator(
186186
private val onFocusBehavior: OnFocusBehavior,
187-
private val focusStack: FocusStack?,
187+
private val focusedViewsList: FocusedViewsList?,
188188
private val windowContext: PlatformWindowContext,
189189
private val coroutineContext: CoroutineContext,
190190
private val redrawer: MetalRedrawer,
@@ -350,7 +350,7 @@ internal class ComposeSceneMediator(
350350
},
351351
view = _overlayView,
352352
viewConfiguration = viewConfiguration,
353-
focusStack = focusStack,
353+
focusedViewsList = focusedViewsList,
354354
onInputStarted = {
355355
animateKeyboardOffsetChanges = true
356356
},
@@ -521,7 +521,7 @@ internal class ComposeSceneMediator(
521521

522522
fun setContent(content: @Composable () -> Unit) {
523523
_overlayView.runOnceOnAppeared {
524-
focusStack?.pushAndFocus(userInputView)
524+
focusedViewsList?.addAndFocus(userInputView)
525525

526526
scene.setContent {
527527
ProvideComposeSceneMediatorCompositionLocals {
@@ -622,7 +622,7 @@ internal class ComposeSceneMediator(
622622
_overlayView.dispose()
623623
textInputService.stopInput()
624624
applicationForegroundStateListener.dispose()
625-
focusStack?.popUntilNext(userInputView)
625+
focusedViewsList?.remove(userInputView)
626626
keyboardManager.dispose()
627627
userInputView.dispose()
628628

compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import androidx.compose.ui.unit.asDpRect
4141
import androidx.compose.ui.unit.round
4242
import androidx.compose.ui.unit.toOffset
4343
import androidx.compose.ui.unit.toRect
44-
import androidx.compose.ui.window.FocusStack
44+
import androidx.compose.ui.window.FocusedViewsList
4545
import androidx.compose.ui.window.MetalView
4646
import kotlin.coroutines.CoroutineContext
4747
import kotlinx.cinterop.CValue
@@ -58,14 +58,14 @@ internal class UIKitComposeSceneLayer(
5858
private val initLayoutDirection: LayoutDirection,
5959
private val onAccessibilityChanged: () -> Unit,
6060
onFocusBehavior: OnFocusBehavior,
61-
focusStack: FocusStack?,
61+
focusedViewsList: FocusedViewsList?,
6262
windowContext: PlatformWindowContext,
6363
compositionContext: CompositionContext,
6464
private val coroutineContext: CoroutineContext,
6565
private val enableBackGesture: Boolean,
6666
) : ComposeSceneLayer {
6767

68-
override var focusable: Boolean = focusStack != null
68+
override var focusable: Boolean = focusedViewsList != null
6969
set(value) {
7070
if (field != value) {
7171
field = value
@@ -89,7 +89,7 @@ internal class UIKitComposeSceneLayer(
8989

9090
private val mediator = ComposeSceneMediator(
9191
onFocusBehavior = onFocusBehavior,
92-
focusStack = focusStack,
92+
focusedViewsList = focusedViewsList,
9393
windowContext = windowContext,
9494
coroutineContext = compositionContext.effectCoroutineContext,
9595
redrawer = metalView.redrawer,

compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/FocusStack.uikit.kt renamed to compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/FocusedViewsList.uikit.kt

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,44 +22,42 @@ import kotlinx.coroutines.delay
2222
import kotlinx.coroutines.launch
2323
import platform.UIKit.UIView
2424

25-
internal class FocusStack {
25+
internal class FocusedViewsList {
2626

2727
private var activeViews = emptyList<UIView>()
2828
private var resignedViews = emptyList<UIView>()
2929
private val mainScope = MainScope()
3030

3131
/**
32-
* Add new view to stack and focus on it.
32+
* Add new view to list and focus on it.
3333
*/
34-
fun pushAndFocus(view: UIView) {
34+
fun addAndFocus(view: UIView) {
3535
activeViews += view
3636
resignedViews -= view
3737
view.becomeFirstResponder()
3838
}
3939

4040
/**
41-
* Pop all elements until some element. Also pop this element too.
42-
* Last remaining element in Stack will be focused.
41+
* Remove the view from the list and resigns first responder.
42+
* The last element in the list will become a new first responder.
4343
*/
44-
fun popUntilNext(view: UIView, delayMillis: Long = 0) {
44+
fun remove(view: UIView, delayMillis: Long = 0) {
4545
if (activeViews.contains(view)) {
46-
val index = activeViews.indexOf(view)
47-
resignedViews += activeViews.subList(index, activeViews.size)
48-
activeViews = activeViews.subList(0, index)
46+
resignedViews += view
47+
activeViews = activeViews.filter { it != view }
4948

5049
mainScope.launch {
5150
delay(delayMillis)
5251
resignedViews.fastForEachReversed {
5352
it.resignFirstResponder()
5453
}
5554
resignedViews = emptyList()
56-
activeViews.lastOrNull()?.becomeFirstResponder()
55+
activeViews.lastOrNull()?.let {
56+
if (!it.isFirstResponder) {
57+
it.becomeFirstResponder()
58+
}
59+
}
5760
}
5861
}
5962
}
60-
61-
/**
62-
* Return first added view or null
63-
*/
64-
fun first(): UIView? = activeViews.firstOrNull()
6563
}

0 commit comments

Comments
 (0)