Skip to content

Commit 6236b82

Browse files
authored
Support isClearFocusOnMouseDownEnabled API (JetBrains#2644)
Add the `isClearFocusOnMouseDownEnabled` API, which allows you to adjust the focus behaviour when clicking on an unfocused element with a mouse or trackpad. Update instrumented tests by adding ability to simulate mouse clicks. Fixes https://youtrack.jetbrains.com/issue/CMP-9323/Support-isClearFocusOnMouseDownEnabled-API-on-iOS ## Release Notes ### Features - iOS - Add ability to adjust `isClearFocusOnMouseDownEnabled` in the `configure` lambda when creating Compose components.
1 parent c6f2289 commit 6236b82

10 files changed

Lines changed: 172 additions & 13 deletions

File tree

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ internal class ComposeContainer(
217217

218218
mediator = ComposeSceneMediator(
219219
onFocusBehavior = configuration.onFocusBehavior,
220+
isClearFocusOnMouseDownEnabled = configuration.isClearFocusOnMouseDownEnabled,
220221
focusedViewsList = focusedViewsList,
221222
windowContext = windowContext,
222223
architectureComponentsOwner = architectureComponentsOwner,
@@ -304,8 +305,7 @@ internal class ComposeContainer(
304305
hostCompositionLocals = { ProvideContainerCompositionLocals(it) },
305306
layersViewController = layersHolder.getLayersViewController(),
306307
initialLayoutDirection = layoutDirection,
307-
onFocusBehavior = configuration.onFocusBehavior,
308-
endEdgeGestureBehavior = configuration.endEdgePanGestureBehavior,
308+
configuration = configuration,
309309
onAccessibilityChanged = ::onAccessibilityChanged,
310310
focusedViewsList = if (focusable) focusedViewsList.childFocusedViewsList() else null,
311311
compositionContext = compositionContext,

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ private class SemanticsOwnerListenerImpl(
184184

185185
internal class ComposeSceneMediator(
186186
private val onFocusBehavior: OnFocusBehavior,
187+
private val isClearFocusOnMouseDownEnabled: Boolean,
187188
focusedViewsList: FocusedViewsList?,
188189
private val windowContext: PlatformWindowContext,
189190
private val architectureComponentsOwner: PlatformArchitectureComponentsOwner,
@@ -722,6 +723,8 @@ internal class ComposeSceneMediator(
722723
override val semanticsOwnerListener get() = this@ComposeSceneMediator.semanticsOwnerListener
723724
override val dragAndDropManager get() = this@ComposeSceneMediator.dragAndDropManager
724725
override val windowInsets get() = this@ComposeSceneMediator.windowInsetsManager.windowInsets
726+
override val isClearFocusOnMouseDownEnabled: Boolean
727+
get() = this@ComposeSceneMediator.isClearFocusOnMouseDownEnabled
725728

726729
override var isKeepScreenOnEnabled: Boolean
727730
get() = UIKitIdleTimerManager.isIdleTimerDisabled

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,9 @@ import androidx.compose.ui.input.pointer.PointerEventType
2929
import androidx.compose.ui.navigationevent.UIKitNavigationEventInput
3030
import androidx.compose.ui.platform.PlatformArchitectureComponentsOwner
3131
import androidx.compose.ui.platform.PlatformContext
32-
import androidx.compose.ui.uikit.EndEdgePanGestureBehavior
32+
import androidx.compose.ui.uikit.ComposeContainerConfiguration
3333
import androidx.compose.ui.uikit.InterfaceOrientation
3434
import androidx.compose.ui.uikit.LocalUIViewController
35-
import androidx.compose.ui.uikit.OnFocusBehavior
3635
import androidx.compose.ui.uikit.density
3736
import androidx.compose.ui.uikit.embedSubview
3837
import androidx.compose.ui.unit.Density
@@ -62,8 +61,7 @@ internal class UIKitComposeSceneLayer(
6261
private val layersViewController: ComposeLayersViewController,
6362
private val initialLayoutDirection: LayoutDirection,
6463
private val onAccessibilityChanged: () -> Unit,
65-
onFocusBehavior: OnFocusBehavior,
66-
endEdgeGestureBehavior: EndEdgePanGestureBehavior,
64+
configuration: ComposeContainerConfiguration,
6765
private var focusedViewsList: FocusedViewsList?,
6866
compositionContext: CompositionContext,
6967
private val ownerProvider: PlatformArchitectureComponentsOwner,
@@ -93,11 +91,12 @@ internal class UIKitComposeSceneLayer(
9391
private val navigationEventInput = UIKitNavigationEventInput(
9492
density = interactionView.density,
9593
getTopLeftOffsetInWindow = { boundsInWindow.topLeft },
96-
endEdgePanGestureBehavior = endEdgeGestureBehavior
94+
endEdgePanGestureBehavior = configuration.endEdgePanGestureBehavior
9795
).also { navigationEventDispatcher.addInput(it) }
9896

9997
private val mediator = ComposeSceneMediator(
100-
onFocusBehavior = onFocusBehavior,
98+
onFocusBehavior = configuration.onFocusBehavior,
99+
isClearFocusOnMouseDownEnabled = configuration.isClearFocusOnMouseDownEnabled,
101100
focusedViewsList = focusedViewsList,
102101
windowContext = layersViewController.windowContext,
103102
architectureComponentsOwner = ownerProvider,

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616

1717
package androidx.compose.ui.uikit
1818

19+
import androidx.compose.ui.ComposeUiFlags
1920
import androidx.compose.ui.ExperimentalComposeUiApi
21+
import androidx.compose.ui.isClearFocusOnMouseDownEnabled
2022

2123
/**
2224
* Base configuration of the Compose container.
@@ -65,6 +67,12 @@ sealed class ComposeContainerConfiguration {
6567
*/
6668
@ExperimentalComposeUiApi
6769
var endEdgePanGestureBehavior: EndEdgePanGestureBehavior = EndEdgePanGestureBehavior.Disabled
70+
71+
/**
72+
* Controls whether a mouse/trackpad clicks on an unfocusable element clear focus.
73+
*/
74+
@ExperimentalComposeUiApi
75+
var isClearFocusOnMouseDownEnabled: Boolean = ComposeUiFlags.isClearFocusOnMouseDownEnabled
6876
}
6977

7078
/**

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class ComposeSceneMediatorTest {
8686
private fun makeMediator(): ComposeSceneMediator {
8787
val mediator = ComposeSceneMediator(
8888
onFocusBehavior = OnFocusBehavior.DoNothing,
89+
isClearFocusOnMouseDownEnabled = false,
8990
focusedViewsList = null,
9091
windowContext = PlatformWindowContext(),
9192
architectureComponentsOwner = DefaultArchitectureComponentsOwner(),

compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/BasicInteractionTest.kt

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,19 @@ import androidx.compose.foundation.layout.safeDrawingPadding
1919
import androidx.compose.foundation.layout.size
2020
import androidx.compose.foundation.text.BasicTextField
2121
import androidx.compose.foundation.text.input.TextFieldState
22+
import androidx.compose.foundation.text.input.rememberTextFieldState
2223
import androidx.compose.foundation.verticalScroll
2324
import androidx.compose.material.Button
2425
import androidx.compose.material.Text
2526
import androidx.compose.material.TextField
27+
import androidx.compose.runtime.LaunchedEffect
2628
import androidx.compose.runtime.MutableState
2729
import androidx.compose.runtime.mutableStateOf
2830
import androidx.compose.ui.Alignment
2931
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.focus.FocusRequester
33+
import androidx.compose.ui.focus.focusRequester
34+
import androidx.compose.ui.focus.onFocusChanged
3035
import androidx.compose.ui.graphics.Color
3136
import androidx.compose.ui.input.pointer.PointerEventPass
3237
import androidx.compose.ui.input.pointer.changedToDown
@@ -303,6 +308,84 @@ class BasicInteractionTest {
303308
}
304309
}
305310

311+
@Test
312+
fun testComposePanelClearFocusOnMouseDownEnabledFlag() = runUIKitInstrumentedTest {
313+
val focusRequester = FocusRequester()
314+
var textFieldIsFocused = false
315+
316+
setContent(
317+
configure = {
318+
isClearFocusOnMouseDownEnabled = true
319+
}
320+
) {
321+
Column {
322+
BasicTextField(
323+
state = rememberTextFieldState(),
324+
modifier = Modifier
325+
.focusRequester(focusRequester)
326+
.onFocusChanged {
327+
textFieldIsFocused = it.isFocused
328+
}
329+
)
330+
Box(Modifier.size(100.dp).testTag("box"))
331+
}
332+
LaunchedEffect(Unit) {
333+
focusRequester.requestFocus()
334+
}
335+
}
336+
337+
assertTrue(textFieldIsFocused)
338+
339+
// No focus changes after tap
340+
findNodeWithTag("box").tap()
341+
waitForIdle()
342+
assertTrue(textFieldIsFocused)
343+
344+
// Clear focus on a click
345+
findNodeWithTag("box").click()
346+
waitForIdle()
347+
assertFalse(textFieldIsFocused)
348+
}
349+
350+
@Test
351+
fun testComposePanelClearFocusOnMouseDownDisabledFlag() = runUIKitInstrumentedTest {
352+
val focusRequester = FocusRequester()
353+
var textFieldIsFocused = false
354+
355+
setContent(
356+
configure = {
357+
isClearFocusOnMouseDownEnabled = false
358+
}
359+
) {
360+
Column {
361+
BasicTextField(
362+
state = rememberTextFieldState(),
363+
modifier = Modifier
364+
.focusRequester(focusRequester)
365+
.onFocusChanged {
366+
textFieldIsFocused = it.isFocused
367+
}
368+
)
369+
Box(Modifier.size(100.dp).testTag("box"))
370+
}
371+
LaunchedEffect(Unit) {
372+
focusRequester.requestFocus()
373+
}
374+
}
375+
376+
assertTrue(textFieldIsFocused)
377+
378+
// No focus changes after tap
379+
findNodeWithTag("box").tap()
380+
waitForIdle()
381+
assertTrue(textFieldIsFocused)
382+
383+
// No focus changes after click
384+
findNodeWithTag("box").click()
385+
waitForIdle()
386+
assertTrue(textFieldIsFocused)
387+
}
388+
306389
private fun UIKitInstrumentedTest.openToolbar(textFieldTag: String) {
307390
findNodeWithTag(textFieldTag).tap()
308391
delay(500)

compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import androidx.compose.ui.scene.ComposeHostingView
2323
import androidx.compose.ui.scene.ComposeHostingViewController
2424
import androidx.compose.ui.test.utils.center
2525
import androidx.compose.ui.test.utils.getTouchesEvent
26+
import androidx.compose.ui.test.utils.mouseDown
2627
import androidx.compose.ui.test.utils.moveToLocationOnWindow
2728
import androidx.compose.ui.test.utils.resetTouches
2829
import androidx.compose.ui.test.utils.toCGPoint
@@ -291,15 +292,34 @@ internal class UIKitInstrumentedTest(
291292
toView = appDelegate.window()
292293
)
293294

294-
val targetWindow = window ?: appDelegate.window()!!
295+
return getTargetWindow(position, window).touchDown(positionOnWindow.asDpOffset())
296+
}
297+
298+
/**
299+
* Simulates a mouse-down event at the specified position on the screen.
300+
*
301+
* @param position The position on the root hosting controller.
302+
* @param window will be used to handle mouse/trackpad click; otherwise,
303+
* the window hosting the view will be used.
304+
* @return A UITouch object representing the mouse/trackpad interaction.
305+
*/
306+
fun mouseDown(position: DpOffset, window: UIWindow? = null): UITouch {
307+
val positionOnWindow = viewController.view.convertPoint(
308+
point = position.toCGPoint(),
309+
toView = appDelegate.window()
310+
)
311+
312+
return getTargetWindow(position, window).mouseDown(positionOnWindow.asDpOffset())
313+
}
314+
315+
private fun getTargetWindow(position: DpOffset, window: UIWindow? = null): UIWindow {
316+
return window ?: appDelegate.window()!!
295317
.windowScene!!
296318
.windows
297319
.findLast {
298320
it as UIWindow
299321
it.hitTest(position.toCGPoint(), it.getTouchesEvent()) != null
300322
} as UIWindow
301-
302-
return targetWindow.touchDown(positionOnWindow.asDpOffset())
303323
}
304324

305325
/**
@@ -311,6 +331,15 @@ internal class UIKitInstrumentedTest(
311331
return touchDown(position).up()
312332
}
313333

334+
/**
335+
* Simulates a click gesture at the specified position on the screen.
336+
*
337+
* @param position The position on the root hosting controller.
338+
*/
339+
fun click(position: DpOffset) {
340+
return mouseDown(position).up()
341+
}
342+
314343
/**
315344
* Simulates a tap gesture for a given AccessibilityTestNode.
316345
*/
@@ -319,6 +348,14 @@ internal class UIKitInstrumentedTest(
319348
return tap(frame.center())
320349
}
321350

351+
/**
352+
* Simulates a trackpad click gesture for a given AccessibilityTestNode.
353+
*/
354+
fun AccessibilityTestNode.click() {
355+
val frame = frame ?: error("Internal error. Frame is missing.")
356+
return click(frame.center())
357+
}
358+
322359
fun AccessibilityTestNode.doubleTap() {
323360
val frame = frame ?: error("Internal error. Frame is missing.")
324361
tap(frame.center())

compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UITouch+Utils.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,28 @@ import kotlinx.cinterop.ExperimentalForeignApi
2828
import platform.UIKit.UIEvent
2929
import platform.UIKit.UITouch
3030
import platform.UIKit.UITouchPhase
31+
import platform.UIKit.UITouchTypeDirect
32+
import platform.UIKit.UITouchTypeIndirect
3133
import platform.UIKit.UIWindow
3234

3335
@OptIn(ExperimentalForeignApi::class)
3436
internal fun UIWindow.touchDown(location: DpOffset): UITouch {
3537
return UITouch.touchAtPoint(
3638
point = location.toCGPoint(),
39+
withType = UITouchTypeDirect,
40+
inWindow = this,
41+
tapCount = 1L,
42+
fromEdge = false
43+
).also {
44+
it.send()
45+
}
46+
}
47+
48+
@OptIn(ExperimentalForeignApi::class)
49+
internal fun UIWindow.mouseDown(location: DpOffset): UITouch {
50+
return UITouch.touchAtPoint(
51+
point = location.toCGPoint(),
52+
withType = UITouchTypeIndirect,
3753
inWindow = this,
3854
tapCount = 1L,
3955
fromEdge = false

testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UITouch+Test.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ NS_ASSUME_NONNULL_BEGIN
2121
@interface UITouch (CMPTest)
2222

2323
+ (instancetype)touchAtPoint:(CGPoint)point
24+
withType:(UITouchType)type
2425
inWindow:(UIWindow *)window
2526
tapCount:(NSInteger)tapCount
2627
fromEdge:(BOOL)fromEdge;

testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UITouch+Test.m

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ - (void)setGestureView:(UIView *)view;
8686
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
8787
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
8888
- (void)_setIsTapToClick:(BOOL)tapToClick;
89+
- (void)_setType:(UITouchType)type;
8990

9091
- (void)_setHidEvent:(IOHIDEventPtr)event;
9192
- (void)_setEdgeType:(NSInteger)edgeType;
@@ -98,10 +99,15 @@ - (UITouchPhase)phase;
9899
@implementation UITouch (CMPTest)
99100

100101
+ (instancetype)touchAtPoint:(CGPoint)point
102+
withType:(UITouchType)type
101103
inWindow:(UIWindow *)window
102104
tapCount:(NSInteger)tapCount
103105
fromEdge:(BOOL)fromEdge {
104-
return [[UITouch alloc] initAtPoint:point inWindow:window tapCount:tapCount fromEdge:fromEdge];
106+
return [[UITouch alloc] initAtPoint:point
107+
withType:type
108+
inWindow:window
109+
tapCount:tapCount
110+
fromEdge:fromEdge];
105111
}
106112

107113
+ (UIEvent *)getTouchesEvent {
@@ -112,7 +118,11 @@ + (void)endAllTouches {
112118
[CMPActiveTouchesHolder.shared.touches removeAllObjects];
113119
}
114120

115-
- (id)initAtPoint:(CGPoint)point inWindow:(UIWindow *)window tapCount:(NSInteger)tapCount fromEdge:(BOOL)fromEdge {
121+
- (id)initAtPoint:(CGPoint)point
122+
withType:(UITouchType)type
123+
inWindow:(UIWindow *)window
124+
tapCount:(NSInteger)tapCount
125+
fromEdge:(BOOL)fromEdge {
116126
self = [super init];
117127
if (self) {
118128
UIView *hitTestView = [window hitTest:point withEvent:[[UIApplication sharedApplication] _touchesEvent]];
@@ -122,6 +132,7 @@ - (id)initAtPoint:(CGPoint)point inWindow:(UIWindow *)window tapCount:(NSInteger
122132
[self setTapCount:tapCount];
123133
[self _setLocationInWindow:point resetPrevious:YES];
124134
[self setPhase:UITouchPhaseBegan];
135+
[self _setType:type];
125136
[self _setEdgeType:fromEdge ? 4 : 0];
126137
[self _setIsFirstTouchForView:YES];
127138

0 commit comments

Comments
 (0)