From 87bb7ee328c14144d0553da0e993a4c27f0dc7d8 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Wed, 13 May 2026 12:00:16 +0200 Subject: [PATCH 01/15] Remove duplicated keys processing --- .../compose/ui/platform/TextInputHelpers.ios.kt | 6 ------ .../compose/ui/platform/UIKitTextInputService.ios.kt | 8 -------- .../compose/ui/scene/ComposeSceneMediator.ios.kt | 1 - .../ui/text/input/ComposeTextInputConnection.ios.kt | 2 -- .../ui/text/input/NativeTextInputConnection.ios.kt | 2 -- .../ui/text/input/SelectionContainerConnection.ios.kt | 1 - .../compose/ui/text/input/TextInputConnection.ios.kt | 1 - .../compose/ui/window/ComposeTextInputView.ios.kt | 10 ---------- .../compose/ui/window/NativeTextInputView.ios.kt | 10 ---------- 9 files changed, 41 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputHelpers.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputHelpers.ios.kt index 22fdd55a23b06..e4b091fcfb9f3 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputHelpers.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputHelpers.ios.kt @@ -47,12 +47,6 @@ import platform.UIKit.UITextWritingDirection internal interface TextEditingDelegate { var inputTraits: SkikoUITextInputTraits - - /** - * Callback to handle keyboard presses. The parameter is a [Set] of [UIPress] objects. - * Erasure happens due to K/N not supporting Obj-C lightweight generics. - */ - var onKeyboardPresses: (Set<*>) -> Unit fun onResignFocus() diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt index 71fca9c7f8bd9..45ed6d9d3e7f5 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt @@ -47,11 +47,6 @@ internal class UIKitTextInputService( private val viewConfiguration: ViewConfiguration, private val focusedViewsList: FocusedViewsList?, private var onInputStarted: () -> Unit, - /** - * Callback to handle keyboard presses. The parameter is a [Set] of [UIPress] objects. - * Erasure happens due to K/N not supporting Obj-C lightweight generics. - */ - private var onKeyboardPresses: (Set<*>) -> Unit, private var focusManager: () -> ComposeSceneFocusManager?, coroutineContext: CoroutineContext ) { @@ -101,7 +96,6 @@ internal class UIKitTextInputService( view = view, coroutineScope = coroutineScope, focusedViewsList = focusedViewsList, - onKeyboardPresses = onKeyboardPresses, focusManager = focusManager ) } else { @@ -111,7 +105,6 @@ internal class UIKitTextInputService( coroutineScope = coroutineScope, viewConfiguration = viewConfiguration, focusedViewsList = focusedViewsList, - onKeyboardPresses = onKeyboardPresses, focusManager = focusManager ) } @@ -203,7 +196,6 @@ internal class UIKitTextInputService( fun dispose() { stopInput() onInputStarted = { } - onKeyboardPresses = { } focusManager = { null } } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 98065a5cc1a44..345757f47c091 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -374,7 +374,6 @@ internal class ComposeSceneMediator( viewConfiguration = viewConfiguration, focusedViewsList = focusedViewsList, onInputStarted = { animateKeyboardOffsetChanges = true }, - onKeyboardPresses = ::onKeyboardPresses, focusManager = { scene.focusManager }, coroutineContext = coroutineContext ).also { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt index ea1766b1cf410..a83c84bfac24b 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt @@ -44,14 +44,12 @@ internal open class ComposeTextInputConnection( coroutineScope: CoroutineScope, viewConfiguration: ViewConfiguration, focusedViewsList: FocusedViewsList?, - onKeyboardPresses: (Set<*>) -> Unit, focusManager: () -> ComposeSceneFocusManager? ) : TextInputConnection( updateView, view, coroutineScope, focusedViewsList, - onKeyboardPresses, focusManager ), TextToolbar { // Fixes a problem where the menu is shown before the textInputView gets its final layout. diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt index e9746d092cb8b..3e8bb53fdf972 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt @@ -53,14 +53,12 @@ internal class NativeTextInputConnection( view: UIView, coroutineScope: CoroutineScope, focusedViewsList: FocusedViewsList?, - onKeyboardPresses: (Set<*>) -> Unit, focusManager: () -> ComposeSceneFocusManager? ) : TextInputConnection( updateView, view, coroutineScope, focusedViewsList, - onKeyboardPresses, focusManager ), NativeTextEditingDelegate { private val scrollView by lazy { NativeTextInputScrollView() } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/SelectionContainerConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/SelectionContainerConnection.ios.kt index 36d34fc6a40f4..7689a2384061d 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/SelectionContainerConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/SelectionContainerConnection.ios.kt @@ -33,7 +33,6 @@ internal class SelectionContainerConnection( coroutineScope, viewConfiguration, null, - {}, focusManager ) { override fun stop() { 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 2f3c0bb0855e6..dbb3d1497d367 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 @@ -51,7 +51,6 @@ internal abstract class TextInputConnection( protected val view: UIView, protected val coroutineScope: CoroutineScope, protected val focusedViewsList: FocusedViewsList?, - override var onKeyboardPresses: (Set<*>) -> Unit, private var focusManager: () -> ComposeSceneFocusManager?, ): TextEditingDelegate { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeTextInputView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeTextInputView.ios.kt index 39e37f3711e3d..1b866062a77cd 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeTextInputView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeTextInputView.ios.kt @@ -113,16 +113,6 @@ internal class ComposeTextInputView( input?.endFloatingCursor() } - override fun pressesBegan(presses: Set<*>, withEvent: UIPressesEvent?) { - input?.onKeyboardPresses(presses) - super.pressesBegan(presses, withEvent) - } - - override fun pressesEnded(presses: Set<*>, withEvent: UIPressesEvent?) { - input?.onKeyboardPresses(presses) - super.pressesEnded(presses, withEvent) - } - /** * A Boolean value that indicates whether the text-entry object has any text. * https://developer.apple.com/documentation/uikit/uikeyinput/1614457-hastext diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/NativeTextInputView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/NativeTextInputView.ios.kt index 85b81024f0da9..1e97f2ccaf0b0 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/NativeTextInputView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/NativeTextInputView.ios.kt @@ -173,16 +173,6 @@ internal class NativeTextInputView input?.endFloatingCursor() } - override fun pressesBegan(presses: Set<*>, withEvent: UIPressesEvent?) { - input?.onKeyboardPresses(presses) - super.pressesBegan(presses, withEvent) - } - - override fun pressesEnded(presses: Set<*>, withEvent: UIPressesEvent?) { - input?.onKeyboardPresses(presses) - super.pressesEnded(presses, withEvent) - } - override fun hitTest(point: CValue, withEvent: UIEvent?): UIView? { return if (input == null) { null From eed5e988042a767eb8c331db3f90231af99258e1 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Wed, 13 May 2026 17:07:57 +0200 Subject: [PATCH 02/15] Focus input view on init. Add tests. --- .../compose/ui/scene/ComposeContainer.ios.kt | 18 +- .../ui/scene/ComposeSceneMediator.ios.kt | 35 +- .../ui/scene/UIKitComposeSceneLayer.ios.kt | 2 +- .../compose/ui/window/InputViews.ios.kt | 5 + .../integrations/ComposeSceneMediatorTest.kt | 2 +- .../ui/interaction/UIPressesEventTest.kt | 199 +++++++++++ .../compose/ui/test/UIKitInstrumentedTest.kt | 26 ++ .../ui/test/utils/UIPressesEvent+Utils.kt | 39 +++ .../CMPTestUtils.xcodeproj/project.pbxproj | 10 +- .../iosMain/objc/CMPTestUtils/CMPTestUtils.h | 1 + .../objc/CMPTestUtils/UIPressesEvent+Test.h | 35 ++ .../objc/CMPTestUtils/UIPressesEvent+Test.m | 315 ++++++++++++++++++ 12 files changed, 673 insertions(+), 14 deletions(-) create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h create mode 100644 testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index 629df2483fb7d..eafd76b3296ed 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -264,7 +264,7 @@ internal class ComposeContainer( architectureComponentsOwner.navigationEventDispatcher.addInput(navigationEventInput) lifecycleDelegate.windowScene = windowScene navigationEventInput.onDidMoveToWindow(view.window, view) - onAccessibilityChanged() + onFocusConditionsChanged() } fun disposeComposeScene() { @@ -306,14 +306,14 @@ internal class ComposeContainer( val layer = UIKitComposeSceneLayer( onClosed = { layersHolder.getLayersViewController().detach(it) - onAccessibilityChanged() + onFocusConditionsChanged() }, createComposeSceneContext = { createComposeSceneContext(it, layersHolder) }, hostCompositionLocals = { ProvideContainerCompositionLocals(it) }, layersViewController = layersHolder.getLayersViewController(), initialLayoutDirection = layoutDirection, configuration = configuration, - onAccessibilityChanged = ::onAccessibilityChanged, + onAccessibilityChanged = ::onFocusConditionsChanged, focusedViewsList = if (focusable) focusedViewsList.childFocusedViewsList() else null, consumePointerInputOutside = consumePointerInputOutside, parentCoroutineContext = compositionContext.effectCoroutineContext, @@ -322,7 +322,7 @@ internal class ComposeContainer( ) layersHolder.getLayersViewController().attach(layer) - onAccessibilityChanged() + onFocusConditionsChanged() return layer } @@ -349,15 +349,15 @@ internal class ComposeContainer( * Enables or disables accessibility for each layer, as well as the root mediator, taking into * account layer order and ability to overlay underlying content. */ - private fun onAccessibilityChanged() { - var isAccessibilityEnabled = true + private fun onFocusConditionsChanged() { + var isFocusEnabled = true layersHolder?.layersViewController?.withLayers { it.fastForEachReversed { layer -> - layer.isAccessibilityEnabled = isAccessibilityEnabled - isAccessibilityEnabled = isAccessibilityEnabled && !layer.focusable + layer.isFocusEnabled = isFocusEnabled + isFocusEnabled = isFocusEnabled && !layer.focusable } } - mediator?.isAccessibilityEnabled = isAccessibilityEnabled + mediator?.isFocusEnabled = isFocusEnabled } private val containingViewController: UIViewController get() { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 345757f47c091..ff3b47f45c675 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -295,6 +295,7 @@ internal class ComposeSceneMediator( * The view handles user touches that occur only over the interop views located on it. */ private val _backgroundView = BackgroundInputView( + onMovedToWindow = ::focusOverlayViewIfNeeded, onLayoutSubviews = ::updateLayout, hitTestInteropView = ::hitTestInteropView, isPointInsideInteractionBounds = ::isPointInsideInteractionBounds, @@ -349,7 +350,16 @@ internal class ComposeSceneMediator( ) } - var isAccessibilityEnabled by semanticsOwnerListener::isEnabled + var isFocusEnabled: Boolean + get() = semanticsOwnerListener.isEnabled + set(value) { + semanticsOwnerListener.isEnabled = value + if (value) { + focusOverlayViewIfNeeded() + } else { + _overlayView.resignFirstResponder() + } + } private val keyboardManager by lazy { ComposeSceneKeyboardOffsetManager( @@ -679,6 +689,29 @@ internal class ComposeSceneMediator( keyboardManager.stop() } + fun focusOverlayViewIfNeeded() { + if (!isFocusEnabled) { + return + } + val window = _overlayView.window ?: return + fun findFirstResponder(view: UIView): UIView? { + if (view.isFirstResponder) { + return view + } + for (subview in view.subviews) { + subview as UIView + val firstResponder = findFirstResponder(subview) + if (firstResponder != null) { + return firstResponder + } + } + return null + } + if (findFirstResponder(window) == null) { + _overlayView.becomeFirstResponder() + } + } + fun setKeyEventListener( onPreviewKeyEvent: ((KeyEvent) -> Boolean)?, onKeyEvent: ((KeyEvent) -> Boolean)? diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt index c8dd2a6fbfdf8..3a40d5056735a 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt @@ -128,7 +128,7 @@ internal class UIKitComposeSceneLayer( val hasInvalidations by mediator::hasInvalidations - var isAccessibilityEnabled by mediator::isAccessibilityEnabled + var isFocusEnabled by mediator::isFocusEnabled override var density: Density get() = mediator.composeSceneDensity diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index fd681691a8057..5f719a960d9a5 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -726,6 +726,7 @@ internal class OverlayInputView( * All other user input events should be handled by the [OverlayInputView] or with its help. */ internal class BackgroundInputView( + private var onMovedToWindow: () -> Unit, private var onLayoutSubviews: () -> Unit, private var hitTestInteropView: (point: CValue) -> UIView?, private var isPointInsideInteractionBounds: (CValue) -> Boolean, @@ -763,6 +764,9 @@ internal class BackgroundInputView( override fun didMoveToWindow() { super.didMoveToWindow() + window?.let { + onMovedToWindow() + } setNeedsLayout() } @@ -800,6 +804,7 @@ internal class BackgroundInputView( removeGestureRecognizer(touchesGestureRecognizer) touchesGestureRecognizer.dispose() + onMovedToWindow = {} hitTestInteropView = { null } isPointInsideInteractionBounds = { false } onLayoutSubviews = {} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt index 5877fa56362f6..d5e82163d6666 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt @@ -56,7 +56,7 @@ class ComposeSceneMediatorTest { mediator.layoutDirection = LayoutDirection.Rtl mediator.compositionLocalContext = null mediator.interactionBounds = IntRect.Zero - mediator.isAccessibilityEnabled = true + mediator.isFocusEnabled = true mediator.prepareAndGetSizeTransitionAnimation { onFrame -> onFrame(1.0f) } Unit } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt new file mode 100644 index 0000000000000..db32c48ab733d --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt @@ -0,0 +1,199 @@ +/* + * 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.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.test.MockAppDelegate +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.utils.beginPress +import androidx.compose.ui.test.utils.release +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readValue +import platform.CoreGraphics.CGRectZero +import platform.UIKit.UIPress +import platform.UIKit.UIPressType +import platform.UIKit.UIPressTypeMenu +import platform.UIKit.UIPressTypeSelect +import platform.UIKit.UIPressTypeUpArrow +import platform.UIKit.UIPressesEvent +import platform.UIKit.UIView +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow + +@OptIn(ExperimentalForeignApi::class) +class UIPressesEventTest { + + private class PressTrackingView : UIView(frame = CGRectZero.readValue()) { + val began = mutableListOf() + val changed = mutableListOf() + val ended = mutableListOf() + val cancelled = mutableListOf() + + override fun canBecomeFirstResponder(): Boolean = true + + override fun pressesBegan(presses: Set<*>, withEvent: UIPressesEvent?) { + presses.forEach { began += (it as UIPress).type } + } + + override fun pressesChanged(presses: Set<*>, withEvent: UIPressesEvent?) { + presses.forEach { changed += (it as UIPress).type } + } + + override fun pressesEnded(presses: Set<*>, withEvent: UIPressesEvent?) { + presses.forEach { ended += (it as UIPress).type } + } + + override fun pressesCancelled(presses: Set<*>, withEvent: UIPressesEvent?) { + presses.forEach { cancelled += (it as UIPress).type } + } + } + + @Test + fun selectPressDispatchesBeganAndEnded() = withPressTrackingView { trackingView, window -> + val event = window.beginPress(UIPressTypeSelect) + assertEquals(listOf(UIPressTypeSelect), trackingView.began) + assertTrue(trackingView.ended.isEmpty()) + + event.release() + assertEquals(listOf(UIPressTypeSelect), trackingView.began) + assertEquals(listOf(UIPressTypeSelect), trackingView.ended) + } + + @Test + fun cancelPressDispatchesCancelled() = withPressTrackingView { trackingView, window -> + val event = window.beginPress(UIPressTypeSelect) + event.release() + + assertEquals(listOf(UIPressTypeSelect), trackingView.began) + assertEquals(listOf(UIPressTypeSelect), trackingView.cancelled) + assertTrue(trackingView.ended.isEmpty()) + } + + private fun withPressTrackingView( + block: (PressTrackingView, UIWindow) -> Unit + ) { + val appDelegate = MockAppDelegate() + val trackingView = PressTrackingView() + val viewController = UIViewController(nibName = null, bundle = null).apply { + view.addSubview(trackingView) + } + try { + appDelegate.setUpWindow(viewController) + UIKitInstrumentedTest.waitUntil( + conditionDescription = "Tracking view attached to window" + ) { trackingView.window != null } + val didBecomeFirstResponder = trackingView.becomeFirstResponder() + assertTrue(didBecomeFirstResponder, "Tracking view failed to become first responder") + assertTrue(trackingView.isFirstResponder(), "Tracking view should be first responder") + + val window = appDelegate.window() ?: error("MockAppDelegate has no window") + + UIKitInstrumentedTest.delay(1) + + block(trackingView, window) + } finally { + appDelegate.cleanUp() + } + } + + @Test + fun composeFocusableReadsKeyInputs() = runUIKitInstrumentedTest { + val requester = FocusRequester() + val keyEvents = mutableListOf>() + + setContent { + LaunchedEffect(Unit) { + requester.requestFocus() + } + Box( + modifier = Modifier + .fillMaxSize() + .focusRequester(requester) + .focusable() + .onKeyEvent { event -> + keyEvents += event.type to event.key + true + } + ) + } + + waitForIdle() + + keystroke(UIPressTypeSelect) + keystroke(UIPressTypeMenu) + keystroke(UIPressTypeUpArrow) + + waitForIdle() + + assertEquals( + listOf( + KeyEventType.KeyDown to Key.DirectionCenter, + KeyEventType.KeyUp to Key.DirectionCenter, + KeyEventType.KeyDown to Key.Menu, + KeyEventType.KeyUp to Key.Menu, + KeyEventType.KeyDown to Key.DirectionUp, + KeyEventType.KeyUp to Key.DirectionUp, + ), + keyEvents, + ) + } + + @Test + fun textFieldTypesHelloFromPresses() = runUIKitInstrumentedTest { + val requester = FocusRequester() + var value by mutableStateOf("") + + setContent { + LaunchedEffect(Unit) { + requester.requestFocus() + } + BasicTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier + .fillMaxSize() + .focusRequester(requester), + ) + } + + "Hello".forEach { char -> + keystroke(char) + waitForIdle() + } + + assertEquals("Hello", value) + } +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 5bbd551a4bc06..1026d8b717828 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -22,10 +22,13 @@ import androidx.compose.ui.platform.InfiniteAnimationPolicy import androidx.compose.ui.scene.ComposeHostingView import androidx.compose.ui.scene.ComposeHostingViewController import androidx.compose.ui.scene.ComposeLayersViewController +import androidx.compose.ui.test.utils.beginKeyPress +import androidx.compose.ui.test.utils.beginPress import androidx.compose.ui.test.utils.center import androidx.compose.ui.test.utils.getTouchesEvent import androidx.compose.ui.test.utils.mouseDown import androidx.compose.ui.test.utils.moveToLocationOnWindow +import androidx.compose.ui.test.utils.release import androidx.compose.ui.test.utils.resetTouches import androidx.compose.ui.test.utils.toCGPoint import androidx.compose.ui.test.utils.touchDown @@ -86,6 +89,7 @@ import platform.UIKit.UIInterfaceOrientationPortrait import platform.UIKit.UIInterfaceOrientationPortraitUpsideDown import platform.UIKit.UIScreen import platform.UIKit.UITextInputProtocol +import platform.UIKit.UIPressType import platform.UIKit.UITouch import platform.UIKit.UIUserInterfaceIdiomPad import platform.UIKit.UIView @@ -374,6 +378,28 @@ internal class UIKitInstrumentedTest( } as UIWindow } + // Presses: + + /** + * Simulates a button press and release for [pressType]. + * + * @param pressType buttons that a person can press. + */ + fun keystroke(pressType: UIPressType) { + val window = appDelegate.window() ?: error("No active window in MockAppDelegate") + return window.beginPress(pressType).release() + } + + /** + * Simulates a hardware-keyboard press and release for [char]. + * + * @param char character to be typed. + */ + fun keystroke(char: Char) { + val window = appDelegate.window() ?: error("No active window in MockAppDelegate") + return window.beginKeyPress(char).release() + } + /** * Simulates a tap gesture at the specified position on the screen. * diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt new file mode 100644 index 0000000000000..f678f83a43bb6 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt @@ -0,0 +1,39 @@ +/* + * 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.test.utils + +import androidx.compose.test.utils.endPress +import androidx.compose.test.utils.keyboardPressEventForCharacter +import androidx.compose.test.utils.pressesEventOfType +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIPressType +import platform.UIKit.UIPressesEvent +import platform.UIKit.UIWindow + +@OptIn(ExperimentalForeignApi::class) +internal fun UIWindow.beginPress(pressType: UIPressType): UIPressesEvent { + return UIPressesEvent.pressesEventOfType(pressType, inWindow = this) + ?: error("UIPressesEvent unavailable on this runtime") +} + +@OptIn(ExperimentalForeignApi::class) +internal fun UIWindow.beginKeyPress(char: Char): UIPressesEvent { + return UIPressesEvent.keyboardPressEventForCharacter(char.toString(), inWindow = this) + ?: error("Cannot synthesise a key press for '$char' — unsupported character.") +} + +internal fun UIPressesEvent.release() = this.endPress() diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj index 6d5bc3bd04b2a..8b5071e246bb9 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 999869392D479FAB0096554D /* HIDEvent.m in Sources */ = {isa = PBXBuildFile; fileRef = 999869362D479FAB0096554D /* HIDEvent.m */; }; 9998693A2D479FAB0096554D /* UITouch+Test.m in Sources */ = {isa = PBXBuildFile; fileRef = 999869382D479FAB0096554D /* UITouch+Test.m */; }; + AB18693A2D479FAB0096554D /* UIPressesEvent+Test.m in Sources */ = {isa = PBXBuildFile; fileRef = AB1869382D479FAB0096554D /* UIPressesEvent+Test.m */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -30,6 +31,8 @@ 999869362D479FAB0096554D /* HIDEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HIDEvent.m; sourceTree = ""; }; 999869372D479FAB0096554D /* UITouch+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UITouch+Test.h"; sourceTree = ""; }; 999869382D479FAB0096554D /* UITouch+Test.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UITouch+Test.m"; sourceTree = ""; }; + AB1869372D479FAB0096554D /* UIPressesEvent+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIPressesEvent+Test.h"; sourceTree = ""; }; + AB1869382D479FAB0096554D /* UIPressesEvent+Test.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIPressesEvent+Test.m"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -68,6 +71,8 @@ 999869362D479FAB0096554D /* HIDEvent.m */, 999869372D479FAB0096554D /* UITouch+Test.h */, 999869382D479FAB0096554D /* UITouch+Test.m */, + AB1869372D479FAB0096554D /* UIPressesEvent+Test.h */, + AB1869382D479FAB0096554D /* UIPressesEvent+Test.m */, ); path = CMPTestUtils; sourceTree = ""; @@ -134,6 +139,7 @@ files = ( 999869392D479FAB0096554D /* HIDEvent.m in Sources */, 9998693A2D479FAB0096554D /* UITouch+Test.m in Sources */, + AB18693A2D479FAB0096554D /* UIPressesEvent+Test.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -266,7 +272,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; OTHER_LDFLAGS = "-ObjC"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -278,7 +284,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; OTHER_LDFLAGS = "-ObjC"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/CMPTestUtils.h b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/CMPTestUtils.h index 38df9dfd1fb9c..9e9b060bde365 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/CMPTestUtils.h +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/CMPTestUtils.h @@ -23,3 +23,4 @@ FOUNDATION_EXPORT double CMPTestUtilsVersionNumber; FOUNDATION_EXPORT const unsigned char CMPTestUtilsVersionString[]; #import "UITouch+Test.h" +#import "UIPressesEvent+Test.h" diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h new file mode 100644 index 0000000000000..d7f0ca9da5ee8 --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h @@ -0,0 +1,35 @@ +/* + * 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. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIPressesEvent (CMPPresses) + ++ (nullable instancetype)pressesEventOfType:(UIPressType)pressType + inWindow:(UIWindow *)window; + ++ (nullable instancetype)keyboardPressEventForCharacter:(NSString *)character + inWindow:(UIWindow *)window; + +- (void)pressChanged; +- (void)endPress; +- (void)cancelPress; + +@end + +NS_ASSUME_NONNULL_END diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m new file mode 100644 index 0000000000000..2ebb13079308f --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m @@ -0,0 +1,315 @@ +/* + * 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. + */ + +#import "UIPressesEvent+Test.h" +#import +#import +#import + +@interface UIPress (CMPTestPrivate) +- (void)setPhase:(UIPressPhase)phase; +- (void)setType:(UIPressType)type; +- (void)setForce:(CGFloat)force; +- (void)setWindow:(UIWindow *)window; +- (void)setTimestamp:(NSTimeInterval)timestamp; +- (void)setClickCount:(NSUInteger)clickCount; +- (void)setModifierFlags:(UIKeyModifierFlags)modifierFlags; +- (void)setGestureRecognizers:(NSArray *)gestureRecognizers; +- (void)setKey:(UIKey *)key; +- (void)_setResponder:(UIResponder *)responder; +- (void)_setSource:(NSUInteger)source; +@end + +@interface UIApplication (CMPTestPrivate) +- (UIEvent *)_touchesEvent; +@end + +@protocol CMPUIKeyInput +- (void)insertText:(NSString *)text; +@end + +#pragma mark - Responder lookup + +static UIResponder *CMPFindFirstResponder(UIView *view) { + if (view.isFirstResponder) { return view; } + for (UIView *sub in view.subviews) { + UIResponder *firstResponder = CMPFindFirstResponder(sub); + if (firstResponder != nil) { return firstResponder; } + } + return nil; +} + +#pragma mark - Event / press construction + +// UIApplication's `_touchesEvent` is the only stable UIKit-produced event we +// can reach from outside UIKit. We borrow its `_eventEnvironment` because the +// native press dispatcher reads that ivar; without it, `sendEvent:` silently +// drops the synthetic event (verified via diagnostic dumps). +static id CMPSharedEventEnvironment(void) { + static id env; + static dispatch_once_t once; + dispatch_once(&once, ^{ + SEL sel = @selector(_touchesEvent); + if (![[UIApplication sharedApplication] respondsToSelector:sel]) { return; } + UIEvent *touchesEvent = ((id(*)(id, SEL))objc_msgSend)([UIApplication sharedApplication], sel); + Ivar envIvar = class_getInstanceVariable([UIEvent class], "_eventEnvironment"); + if (envIvar != NULL && touchesEvent != nil) { + env = object_getIvar(touchesEvent, envIvar); + } + }); + return env; +} + +// Subclassing `UIPressesEvent` (objc_allocateClassPair + method overrides) did +// not survive UIKit's native dispatch — `sendEvent:` checked the concrete class +// or read backing ivars and dropped the event. We allocate the real class and +// populate its private ivars instead. +static UIPressesEvent *CMPMakePressesEvent(UIPress *press) { + Class cls = NSClassFromString(@"UIPressesEvent"); + if (cls == Nil) { return nil; } + SEL initSel = NSSelectorFromString(@"_init"); + UIPressesEvent *event = nil; + if ([cls instancesRespondToSelector:initSel]) { + event = ((id(*)(id, SEL))objc_msgSend)([cls alloc], initSel); + } else { + event = [[cls alloc] init]; + } + if (event == nil) { return nil; } + + Ivar allPressesIvar = class_getInstanceVariable(cls, "_allPresses"); + if (allPressesIvar != NULL) { + object_setIvar(event, allPressesIvar, [NSMutableSet setWithObject:press]); + } + Ivar lastPreparedIvar = class_getInstanceVariable(cls, "_lastPreparedPress"); + if (lastPreparedIvar != NULL) { + object_setIvar(event, lastPreparedIvar, press); + } + + Ivar envIvar = class_getInstanceVariable([UIEvent class], "_eventEnvironment"); + id env = CMPSharedEventEnvironment(); + if (envIvar != NULL && env != nil) { + object_setIvar(event, envIvar, env); + } + + return event; +} + +static UIPress *CMPMakePress(UIPressType pressType, + UIPressPhase phase, + UIWindow *window, + UIResponder *responder) { + UIPress *press = [[UIPress alloc] init]; + if (press == nil) { return nil; } + + [press setPhase:phase]; + [press setType:pressType]; + [press setForce:phase == UIPressPhaseEnded ? 0.0 : 1.0]; + [press setWindow:window]; + [press setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; + [press setClickCount:1]; + [press setModifierFlags:0]; + [press setGestureRecognizers:@[]]; + [press _setResponder:responder]; + [press _setSource:1]; + + return press; +} + +#pragma mark - Dispatch + +static void CMPDispatchPresses(UIEvent *event, + UIPress *press, + UIPressPhase phase) { + [press setPhase:phase]; + [press setForce:phase == UIPressPhaseEnded ? 0.0 : 1.0]; + [press setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; + + [[UIApplication sharedApplication] sendEvent:event]; +} + +#pragma mark - UIKey construction (for typing) + +static Ivar CMPFindIvar(Class cls, NSArray *candidateNames) { + Class current = cls; + while (current != Nil) { + for (NSString *name in candidateNames) { + Ivar iv = class_getInstanceVariable(current, name.UTF8String); + if (iv != NULL) { return iv; } + } + current = class_getSuperclass(current); + } + return NULL; +} + +static void CMPSetObjectIvar(id obj, NSArray *names, id value) { + Ivar iv = CMPFindIvar([obj class], names); + if (iv != NULL) { object_setIvar(obj, iv, value); } +} + +static void CMPSetIntegerIvar(id obj, NSArray *names, NSInteger value) { + Ivar iv = CMPFindIvar([obj class], names); + if (iv == NULL) { return; } + uint8_t *base = (uint8_t *)(__bridge void *)obj; + *(NSInteger *)(base + ivar_getOffset(iv)) = value; +} + +static UIKey *CMPMakeUIKey(NSString *characters, + NSString *unmodifiedCharacters, + NSInteger keyCode, + NSInteger modifierFlags) { + // UIKey has no public initializer — set its private ivars directly. + Class cls = NSClassFromString(@"UIKey"); + if (cls == Nil) { return nil; } + UIKey *key = [[cls alloc] init]; + if (key == nil) { return nil; } + + CMPSetObjectIvar(key, @[ @"_characters" ], characters); + CMPSetObjectIvar(key, + @[ @"_charactersIgnoringModifiers", @"_unmodifiedCharacters" ], + unmodifiedCharacters ?: characters); + CMPSetIntegerIvar(key, @[ @"_keyCode" ], keyCode); + CMPSetIntegerIvar(key, @[ @"_modifierFlags" ], modifierFlags); + + return key; +} + +static BOOL CMPHIDKeyCodeForCharacter(unichar c, + NSInteger *outKeyCode, + NSInteger *outModifierFlags, + NSString **outUnmodifiedCharacters) { + unichar lower = c; + NSInteger modifierFlags = 0; + if (c >= 'A' && c <= 'Z') { + lower = (unichar)(c + ('a' - 'A')); + modifierFlags = UIKeyModifierShift; + } + NSInteger keyCode = 0; + if (lower >= 'a' && lower <= 'z') { + keyCode = 4 + (lower - 'a'); + } else if (lower >= '1' && lower <= '9') { + keyCode = 30 + (lower - '1'); + } else if (lower == '0') { + keyCode = 39; + } else if (lower == ' ') { + keyCode = 44; + } else { + return NO; + } + if (outKeyCode != NULL) { *outKeyCode = keyCode; } + if (outModifierFlags != NULL) { *outModifierFlags = modifierFlags; } + if (outUnmodifiedCharacters != NULL) { + *outUnmodifiedCharacters = [NSString stringWithCharacters:&lower length:1]; + } + return YES; +} + +static UIPress *CMPMakeKeyboardPress(NSString *characters, + NSString *unmodifiedCharacters, + NSInteger keyCode, + NSInteger modifierFlags, + UIWindow *window, + UIResponder *responder) { + UIPress *press = [[UIPress alloc] init]; + if (press == nil) { return nil; } + + [press setPhase:UIPressPhaseBegan]; + [press setType:(UIPressType)(2000 + keyCode)]; + [press setForce:1.0]; + [press setWindow:window]; + [press setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; + [press setClickCount:1]; + [press setModifierFlags:(UIKeyModifierFlags)modifierFlags]; + [press setGestureRecognizers:@[]]; + [press _setResponder:responder]; + [press _setSource:1]; + + UIKey *key = CMPMakeUIKey(characters, unmodifiedCharacters, keyCode, modifierFlags); + if (key != nil && [press respondsToSelector:@selector(setKey:)]) { + [press setKey:key]; + } + + return press; +} + +@implementation UIPressesEvent (CMPPresses) + ++ (nullable instancetype)pressesEventOfType:(UIPressType)pressType + inWindow:(UIWindow *)window { + UIResponder *target = CMPFindFirstResponder(window); + + UIPress *press = CMPMakePress(pressType, UIPressPhaseBegan, window, target); + if (press == nil) { return nil; } + + UIPressesEvent *event = CMPMakePressesEvent(press); + if (event == nil) { return nil; } + + CMPDispatchPresses(event, press, UIPressPhaseBegan); + return event; +} + ++ (nullable instancetype)keyboardPressEventForCharacter:(NSString *)character + inWindow:(UIWindow *)window { + if (character.length != 1) { return nil; } + unichar c = [character characterAtIndex:0]; + NSInteger keyCode = 0; + NSInteger modifierFlags = 0; + NSString *unmodifiedCharacters = nil; + if (!CMPHIDKeyCodeForCharacter(c, &keyCode, &modifierFlags, &unmodifiedCharacters)) { + return nil; + } + + UIResponder *target = CMPFindFirstResponder(window); + UIPress *press = CMPMakeKeyboardPress(character, unmodifiedCharacters, + keyCode, modifierFlags, window, target); + if (press == nil) { return nil; } + + UIPressesEvent *event = CMPMakePressesEvent(press); + if (event == nil) { return nil; } + + CMPDispatchPresses(event, press, UIPressPhaseBegan); + + // UIKit's hardware-key→text pipeline (`insertText:` on a focused + // `UIKeyInput`) doesn't fire reliably for synthetic events. Drive that + // final hop ourselves so a focused TextField actually receives the typed + // character; the press dispatch above still runs the standard responder + // hooks for tests that observe pressesBegan/Ended. + if (target != nil && + [target respondsToSelector:@selector(insertText:)]) { + [(id)target insertText:character]; + } + + return event; +} + +- (void)pressChanged { + UIPress *press = ((UIPressesEvent *)self).allPresses.anyObject; + if (press == nil) { return; } + CMPDispatchPresses(self, press, UIPressPhaseChanged); +} + +- (void)endPress { + UIPress *press = ((UIPressesEvent *)self).allPresses.anyObject; + if (press == nil) { return; } + CMPDispatchPresses(self, press, UIPressPhaseEnded); +} + +- (void)cancelPress { + UIPress *press = ((UIPressesEvent *)self).allPresses.anyObject; + if (press == nil) { return; } + CMPDispatchPresses(self, press, UIPressPhaseCancelled); +} + +@end From c1a3a5d43886de0993875dcdf3bb3fc41c0168d1 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Wed, 13 May 2026 17:33:33 +0200 Subject: [PATCH 03/15] Update tests --- .../ui/interaction/UIPressesEventTest.kt | 31 ++++++++++++++++++- .../compose/ui/test/UIKitInstrumentedTest.kt | 10 ++++++ .../ui/test/utils/UIPressesEvent+Utils.kt | 13 ++++++-- .../objc/CMPTestUtils/UIPressesEvent+Test.h | 1 + .../objc/CMPTestUtils/UIPressesEvent+Test.m | 11 +++++-- 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt index db32c48ab733d..c0f7b3599fe59 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,8 +44,9 @@ import kotlin.test.assertTrue import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.readValue import platform.CoreGraphics.CGRectZero +import platform.UIKit.UIKeyModifierCommand +import platform.UIKit.UIPasteboard import platform.UIKit.UIPress -import platform.UIKit.UIPressType import platform.UIKit.UIPressTypeMenu import platform.UIKit.UIPressTypeSelect import platform.UIKit.UIPressTypeUpArrow @@ -196,4 +198,31 @@ class UIPressesEventTest { assertEquals("Hello", value) } + + @Test + fun pasteTextUsingHotkeyIntoBasicTextField() = runUIKitInstrumentedTest { + UIPasteboard.generalPasteboard().string = "Pasted" + + val requester = FocusRequester() + val state = TextFieldState() + + setContent { + LaunchedEffect(Unit) { + requester.requestFocus() + } + BasicTextField( + state = state, + modifier = Modifier + .fillMaxSize() + .focusRequester(requester), + ) + } + + waitForIdle() + + keystroke('v', modifierFlags = UIKeyModifierCommand) + waitUntil("BasicTextField should contain pasteboard text") { + state.text.toString() == "Pasted" + } + } } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 1026d8b717828..458d636dac6da 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -89,6 +89,7 @@ import platform.UIKit.UIInterfaceOrientationPortrait import platform.UIKit.UIInterfaceOrientationPortraitUpsideDown import platform.UIKit.UIScreen import platform.UIKit.UITextInputProtocol +import platform.UIKit.UIKeyModifierFlags import platform.UIKit.UIPressType import platform.UIKit.UITouch import platform.UIKit.UIUserInterfaceIdiomPad @@ -400,6 +401,15 @@ internal class UIKitInstrumentedTest( return window.beginKeyPress(char).release() } + /** + * Simulates a hardware-keyboard shortcut press and release for [char] combined with + * the given [modifierFlags] (e.g. `UIKeyModifierCommand` for ⌘-shortcuts). + */ + fun keystroke(char: Char, modifierFlags: UIKeyModifierFlags) { + val window = appDelegate.window() ?: error("No active window in MockAppDelegate") + return window.beginKeyPress(char, modifierFlags).release() + } + /** * Simulates a tap gesture at the specified position on the screen. * diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt index f678f83a43bb6..8b98da2bea7a0 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt @@ -20,6 +20,7 @@ import androidx.compose.test.utils.endPress import androidx.compose.test.utils.keyboardPressEventForCharacter import androidx.compose.test.utils.pressesEventOfType import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIKeyModifierFlags import platform.UIKit.UIPressType import platform.UIKit.UIPressesEvent import platform.UIKit.UIWindow @@ -31,9 +32,15 @@ internal fun UIWindow.beginPress(pressType: UIPressType): UIPressesEvent { } @OptIn(ExperimentalForeignApi::class) -internal fun UIWindow.beginKeyPress(char: Char): UIPressesEvent { - return UIPressesEvent.keyboardPressEventForCharacter(char.toString(), inWindow = this) - ?: error("Cannot synthesise a key press for '$char' — unsupported character.") +internal fun UIWindow.beginKeyPress( + char: Char, + modifierFlags: UIKeyModifierFlags = 0, +): UIPressesEvent { + return UIPressesEvent.keyboardPressEventForCharacter( + char.toString(), + modifierFlags = modifierFlags, + inWindow = this, + ) ?: error("Cannot synthesise a key press for '$char' — unsupported character.") } internal fun UIPressesEvent.release() = this.endPress() diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h index d7f0ca9da5ee8..4439ef29cad00 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h @@ -24,6 +24,7 @@ NS_ASSUME_NONNULL_BEGIN inWindow:(UIWindow *)window; + (nullable instancetype)keyboardPressEventForCharacter:(NSString *)character + modifierFlags:(UIKeyModifierFlags)modifierFlags inWindow:(UIWindow *)window; - (void)pressChanged; diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m index 2ebb13079308f..ceb7eefe3452e 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m @@ -261,7 +261,8 @@ + (nullable instancetype)pressesEventOfType:(UIPressType)pressType } + (nullable instancetype)keyboardPressEventForCharacter:(NSString *)character - inWindow:(UIWindow *)window { + modifierFlags:(UIKeyModifierFlags)extraModifierFlags + inWindow:(UIWindow *)window { if (character.length != 1) { return nil; } unichar c = [character characterAtIndex:0]; NSInteger keyCode = 0; @@ -270,6 +271,7 @@ + (nullable instancetype)keyboardPressEventForCharacter:(NSString *)character if (!CMPHIDKeyCodeForCharacter(c, &keyCode, &modifierFlags, &unmodifiedCharacters)) { return nil; } + modifierFlags |= (NSInteger)extraModifierFlags; UIResponder *target = CMPFindFirstResponder(window); UIPress *press = CMPMakeKeyboardPress(character, unmodifiedCharacters, @@ -286,7 +288,12 @@ + (nullable instancetype)keyboardPressEventForCharacter:(NSString *)character // final hop ourselves so a focused TextField actually receives the typed // character; the press dispatch above still runs the standard responder // hooks for tests that observe pressesBegan/Ended. - if (target != nil && + // Skip when a command/control/alt modifier is set — those are keyboard + // shortcuts (e.g. ⌘V), not text insertion. + UIKeyModifierFlags shortcutModifiers = + UIKeyModifierCommand | UIKeyModifierControl | UIKeyModifierAlternate; + BOOL isShortcut = (extraModifierFlags & shortcutModifiers) != 0; + if (!isShortcut && target != nil && [target respondsToSelector:@selector(insertText:)]) { [(id)target insertText:character]; } From 3331c5b2ee48d5ea845f7aa731838d67e8ccd3f0 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 18 May 2026 17:32:31 +0200 Subject: [PATCH 04/15] Disable edit actions when handled keys are pressed --- .../CMPUIKitUtils/CMPEditMenuView.m | 6 ++ .../ui/platform/UIKitTextInputService.ios.kt | 30 +++++++-- .../ui/scene/ComposeSceneMediator.ios.kt | 64 +++++++++++++++++-- .../input/ComposeTextInputConnection.ios.kt | 9 ++- .../compose/ui/window/InputViews.ios.kt | 12 ++++ .../ui/interaction/UIPressesEventTest.kt | 1 + 6 files changed, 107 insertions(+), 15 deletions(-) diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m index be5bab646ec72..a4560b9c1cf56 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m @@ -286,6 +286,12 @@ - (void)hideEditMenu { [self cancelShowMenuController]; [[UIMenuController sharedMenuController] hideMenu]; } + + self.copyBlock = nil; + self.cutBlock = nil; + self.pasteBlock = nil; + self.selectAllBlock = nil; + self.customActions = @[]; } - (BOOL)contextMenuItemsChangedCopy:(void (^)(void))copyBlock diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt index 45ed6d9d3e7f5..ca8f087eb3c6c 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt @@ -37,7 +37,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import platform.UIKit.UIPress import platform.UIKit.UIView @OptIn(ExperimentalComposeUiApi::class) @@ -48,6 +47,7 @@ internal class UIKitTextInputService( private val focusedViewsList: FocusedViewsList?, private var onInputStarted: () -> Unit, private var focusManager: () -> ComposeSceneFocusManager?, + private var hasActiveKeyDown: () -> Boolean, coroutineContext: CoroutineContext ) { @@ -132,7 +132,7 @@ internal class UIKitTextInputService( val textToolbar: TextToolbar = object : TextToolbar { override val status: TextToolbarStatus - get() = (currentInputConnection as? TextToolbar)?.status ?: TextToolbarStatus.Hidden + get() = (currentInputConnection as? ComposeTextInputConnection)?.toolbarStatus ?: TextToolbarStatus.Hidden override fun showMenu( rect: Rect, @@ -153,13 +153,17 @@ internal class UIKitTextInputService( view, coroutineScope, viewConfiguration, focusManager ) } - (currentInputConnection as? TextToolbar)?.showMenu( - rect, onCopyRequested, onPasteRequested, onCutRequested, onSelectAllRequested + (currentInputConnection as? ComposeTextInputConnection)?.showToolbarMenu( + rect, + ignoringActiveKeyDown(onCopyRequested), + ignoringActiveKeyDown(onPasteRequested), + ignoringActiveKeyDown(onCutRequested), + ignoringActiveKeyDown(onSelectAllRequested), ) } override fun hide() { - (currentInputConnection as? TextToolbar)?.hide() + (currentInputConnection as? ComposeTextInputConnection)?.hideToolbar() if (currentInputConnection is SelectionContainerConnection) { // stop() removes the view from the hierarchy and resigns first responder, @@ -182,7 +186,11 @@ internal class UIKitTextInputService( customActions: List? ) { (currentInputConnection as? NativeTextInputConnection)?.updateNativeTextInputEditMenuState( - copy, paste, cut, selectAll, customActions + ignoringActiveKeyDown(copy), + ignoringActiveKeyDown(paste), + ignoringActiveKeyDown(cut), + ignoringActiveKeyDown(selectAll), + customActions ) } @@ -193,9 +201,19 @@ internal class UIKitTextInputService( } } + private fun ignoringActiveKeyDown(action: (() -> Unit)?): (() -> Unit)? { + if (action == null) return null + return { + if (!hasActiveKeyDown()) { + action() + } + } + } + fun dispose() { stopInput() onInputStarted = { } focusManager = { null } + hasActiveKeyDown = { false } } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index ff3b47f45c675..9f815cb257769 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -64,6 +64,13 @@ import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.uikit.density import androidx.compose.ui.uikit.toNanoSeconds import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.input.key.internal +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerKeyboardModifiers +import androidx.compose.ui.input.pointer.isAltPressed +import androidx.compose.ui.input.pointer.isCtrlPressed +import androidx.compose.ui.input.pointer.isMetaPressed +import androidx.compose.ui.input.pointer.isShiftPressed import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntRect @@ -285,7 +292,8 @@ internal class ComposeSceneMediator( onCancelScroll = ::onCancelScroll, onHoverEvent = ::onHoverEvent, onKeyboardPresses = ::onKeyboardPresses, - ignoreTouchChanges = navigationEventInput::isBackGestureActive + ignoreTouchChanges = navigationEventInput::isBackGestureActive, + onCancelKeyboardPresses = ::onCancelKeyboardPresses, ) val overlayView: UIView get() = _overlayView @@ -385,7 +393,8 @@ internal class ComposeSceneMediator( focusedViewsList = focusedViewsList, onInputStarted = { animateKeyboardOffsetChanges = true }, focusManager = { scene.focusManager }, - coroutineContext = coroutineContext + hasActiveKeyDown = { pressedKeysToHandledState.values.contains(true) }, + coroutineContext = coroutineContext, ).also { KeyboardVisibilityListener.initialize() } @@ -731,13 +740,60 @@ internal class ComposeSceneMediator( } } - private fun onKeyboardEvent(keyEvent: KeyEvent): Boolean = - textInputService.onPreviewKeyEvent(keyEvent) // TODO: fix redundant call + private data class KeyIdentifier( + val key: Key, + val codePoint: Int, + val modifiers: PointerKeyboardModifiers + ) + + private fun KeyEvent.keyIdentifier(): KeyIdentifier { + val internalEvent = internal + return KeyIdentifier(internalEvent.key, internalEvent.codePoint, internalEvent.modifiers) + } + + private val pressedKeysToHandledState = mutableMapOf() + + private fun onCancelKeyboardPresses() { + if (pressedKeysToHandledState.isEmpty()) { + return + } + pressedKeysToHandledState.toList().reversed().forEach { (key, _) -> + onKeyboardEvent( + KeyEvent( + key = key.key, + type = KeyEventType.KeyUp, + codePoint = key.codePoint, + isCtrlPressed = key.modifiers.isCtrlPressed, + isMetaPressed = key.modifiers.isMetaPressed, + isAltPressed = key.modifiers.isAltPressed, + isShiftPressed = key.modifiers.isShiftPressed, + nativeEvent = null, + ) + ) + } + } + + private fun onKeyboardEvent(keyEvent: KeyEvent): Boolean { + val result = textInputService.onPreviewKeyEvent(keyEvent) // TODO: fix redundant call || onPreviewKeyEvent(keyEvent) || scene.sendKeyEvent(keyEvent) || onKeyEvent(keyEvent) || navigationEventInput.onKeyEvent(keyEvent) + val identifier = keyEvent.keyIdentifier() + if (keyEvent.type == KeyEventType.KeyDown) { + pressedKeysToHandledState[identifier] = result + } else if (keyEvent.type == KeyEventType.KeyUp) { + if (pressedKeysToHandledState.contains(identifier)) { + pressedKeysToHandledState.remove(identifier) + } else { + pressedKeysToHandledState.clear() + } + } + + return result + } + private inner class PlatformContextImpl : PlatformContext { override val windowInfo: WindowInfo get() = windowContext.windowInfo override val architectureComponentsOwner get() = this@ComposeSceneMediator.architectureComponentsOwner diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt index a83c84bfac24b..4f136fd68a3f3 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt @@ -51,7 +51,7 @@ internal open class ComposeTextInputConnection( coroutineScope, focusedViewsList, focusManager -), TextToolbar { +) { // Fixes a problem where the menu is shown before the textInputView gets its final layout. private var showMenuOrUpdatePosition = {} @@ -112,14 +112,14 @@ internal open class ComposeTextInputConnection( showMenuOrUpdatePosition() } - override val status: TextToolbarStatus + val toolbarStatus: TextToolbarStatus get() = if (textInputView.isTextMenuShown()) { TextToolbarStatus.Shown } else { TextToolbarStatus.Hidden } - override fun showMenu( + fun showToolbarMenu( rect: Rect, onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, @@ -145,8 +145,7 @@ internal open class ComposeTextInputConnection( showMenuOrUpdatePosition() } - - override fun hide() { + fun hideToolbar() { showMenuOrUpdatePosition = {} textInputView.let { it.hideTextMenu() diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index 5f719a960d9a5..004978d334063 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -513,6 +513,7 @@ internal class OverlayInputView( onCancelScroll: () -> Unit, private var onHoverEvent: (position: DpOffset, event: UIEvent?, eventKind: TouchesEventKind) -> Unit, private var onKeyboardPresses: (Set<*>) -> Unit, + private var onCancelKeyboardPresses: () -> Unit, ignoreTouchChanges: () -> Boolean, ) : CMPScrollView(CGRectZero.readValue()) { /** @@ -580,6 +581,16 @@ internal class OverlayInputView( override fun canBecomeFocused(): Boolean = false + override fun resignFirstResponder(): Boolean { + onCancelKeyboardPresses() + return super.resignFirstResponder() + } + + override fun becomeFirstResponder(): Boolean { + onCancelKeyboardPresses() + return super.becomeFirstResponder() + } + override fun pressesBegan(presses: Set<*>, withEvent: UIPressesEvent?) { onKeyboardPresses(presses) super.pressesBegan(presses, withEvent) @@ -716,6 +727,7 @@ internal class OverlayInputView( onOutsidePointerEvent = {} onTouchesEvent = { _, _, _ -> PointerEventResult() } onCancelAllTouches = {} + onCancelKeyboardPresses = {} trackedTouchesOutside.clear() } } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt index c0f7b3599fe59..619684a6b40b1 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt @@ -47,6 +47,7 @@ import platform.CoreGraphics.CGRectZero import platform.UIKit.UIKeyModifierCommand import platform.UIKit.UIPasteboard import platform.UIKit.UIPress +import platform.UIKit.UIPressType import platform.UIKit.UIPressTypeMenu import platform.UIKit.UIPressTypeSelect import platform.UIKit.UIPressTypeUpArrow From 4ee9edff79b475f968a305ca7a96af5e6cb07eee Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 19 May 2026 10:23:53 +0200 Subject: [PATCH 05/15] Add text field hotkey tests --- .../compose/ui/input/key/KeyEvent.ios.kt | 3 +- .../compose/ui/window/InputViews.ios.kt | 5 + .../ui/interaction/TextFieldEditMenuTest.kt | 17 +- .../ui/interaction/TextFieldHotkeyTest.kt | 181 ++++++++++++++++++ .../ui/interaction/UIPressesEventTest.kt | 75 +++++--- .../compose/ui/test/UIKitInstrumentedTest.kt | 59 ++++++ .../ui/test/utils/UIPressesEvent+Utils.kt | 32 ++++ .../objc/CMPTestUtils/UIPressesEvent+Test.h | 4 + .../objc/CMPTestUtils/UIPressesEvent+Test.m | 28 +++ 9 files changed, 364 insertions(+), 40 deletions(-) create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/input/key/KeyEvent.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/input/key/KeyEvent.ios.kt index f49a881fe59e9..fc0b7e28c97e2 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/input/key/KeyEvent.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/input/key/KeyEvent.ios.kt @@ -24,6 +24,7 @@ import platform.UIKit.UIKeyModifierFlags import platform.UIKit.UIKeyModifierShift import platform.UIKit.UIPress import platform.UIKit.UIPressPhase.UIPressPhaseBegan +import platform.UIKit.UIPressPhase.UIPressPhaseCancelled import platform.UIKit.UIPressPhase.UIPressPhaseEnded import platform.UIKit.UIPressTypeDownArrow import platform.UIKit.UIPressTypeLeftArrow @@ -38,7 +39,7 @@ import platform.UIKit.UIPressTypeUpArrow internal fun UIPress.toComposeEvent(): KeyEvent { val keyEventType = when (phase) { UIPressPhaseBegan -> KeyEventType.KeyDown - UIPressPhaseEnded -> KeyEventType.KeyUp + UIPressPhaseEnded, UIPressPhaseCancelled -> KeyEventType.KeyUp else -> KeyEventType.Unknown } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index 004978d334063..c5967c6ef9f21 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -601,6 +601,11 @@ internal class OverlayInputView( super.pressesEnded(presses, withEvent) } + override fun pressesCancelled(presses: Set<*>, withEvent: UIPressesEvent?) { + onKeyboardPresses(presses) + super.pressesCancelled(presses, withEvent) + } + private val trackedTouchesOutside: MutableSet = mutableSetOf() private fun handleTouchesEvent( touches: Set<*>, event: UIEvent?, phase: TouchesEventKind diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt index efbf221e011ea..0f0fa855d0cd5 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.test.firstNodeOrNull import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.test.utils.hold import androidx.compose.ui.test.utils.up +import androidx.compose.ui.test.waitForContextMenu import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import kotlin.test.Test @@ -487,20 +488,4 @@ class TextFieldEditMenuTest { .up() } } - - private fun UIKitInstrumentedTest.waitForContextMenu() { - val menuClassName = if (available(OS.Ios to OSVersion(16))) { - "_UIEditMenuContainerView" - } else { - "UICalloutBar" - } - waitForIdle() - waitUntil { - firstNodeOrNull { node -> - node.element?.let { it::class.simpleName } == menuClassName - } != null - } - // Additional delay to wait until toolbar animation ends - delay(500) - } } \ No newline at end of file diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt new file mode 100644 index 0000000000000..d3096b44ad52f --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt @@ -0,0 +1,181 @@ +/* + * 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.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.waitForContextMenu +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIKeyModifierCommand +import platform.UIKit.UIPasteboard + +@OptIn(ExperimentalForeignApi::class) +class TextFieldHotkeyTest { + + @Test + fun copySelectedText() = runTestsWithTextField( + initialText = "Hello World", + initialSelection = TextRange(0, 5), + actions = { + UIPasteboard.generalPasteboard().string = null + keystroke('c', modifierFlags = UIKeyModifierCommand) + }, + validate = { + assertEquals("Hello", UIPasteboard.generalPasteboard().string) + } + ) + + @Test + fun cutSelectedText() = runTestsWithTextField( + initialText = "Hello World", + initialSelection = TextRange(0, 5), + actions = { + UIPasteboard.generalPasteboard().string = null + keystroke('x', modifierFlags = UIKeyModifierCommand) + }, + validate = { value -> + assertEquals("Hello", UIPasteboard.generalPasteboard().string) + assertEquals(" World", value.text) + } + ) + + @Test + fun selectAllText() = runTestsWithTextField( + initialText = "Hello World", + actions = { + keystroke('a', modifierFlags = UIKeyModifierCommand) + }, + validate = { value -> + assertEquals(TextRange(0, "Hello World".length), value.selection) + } + ) + + @Test + fun pasteClipboardAtCursor() = runTestsWithTextField( + initialText = "Hello", + initialSelection = TextRange(5), + actions = { + UIPasteboard.generalPasteboard().string = " World" + keystroke('v', modifierFlags = UIKeyModifierCommand) + }, + validate = { value -> + assertEquals("Hello World", value.text) + } + ) + + @Test + fun pasteClipboardReplacesSelection() = runTestsWithTextField( + initialText = "Hello World", + initialSelection = TextRange(6, 11), + actions = { + UIPasteboard.generalPasteboard().string = "Kotlin" + keystroke('v', modifierFlags = UIKeyModifierCommand) + }, + validate = { value -> + assertEquals("Hello Kotlin", value.text) + } + ) + + @Test + fun showEditMenuThenPasteWithHotkey() = runTestsWithTextField( + initialText = "Hello-LongLongLongLongLongLong-text", + actions = { + // Select a word and bring up the edit menu via double-tap. + findNodeWithTag("TextField").tap() + delay(500) + findNodeWithTag("TextField").doubleTap() + waitForContextMenu() + + // Replace the selected word with clipboard content via the keyboard shortcut, + // without touching any menu button. + UIPasteboard.generalPasteboard().string = "Kotlin" + keystroke('v', modifierFlags = UIKeyModifierCommand) + }, + validate = { value -> + // Double-tap selects "LongLongLongLongLongLong" (the long middle word). + assertEquals("Hello-Kotlin-text", value.text) + } + ) + + private fun runTestsWithTextField( + initialText: String, + initialSelection: TextRange = TextRange.Zero, + actions: UIKitInstrumentedTest.() -> Unit, + validate: UIKitInstrumentedTest.(value: TextFieldValue) -> Unit, + ) { + println("BasicTextField:") + runUIKitInstrumentedTest { + val requester = FocusRequester() + val valueState = mutableStateOf(TextFieldValue(initialText, initialSelection)) + + setContent { + LaunchedEffect(Unit) { requester.requestFocus() } + Column(modifier = Modifier.safeDrawingPadding()) { + BasicTextField( + value = valueState.value, + onValueChange = { valueState.value = it }, + modifier = Modifier + .fillMaxSize() + .focusRequester(requester) + .testTag("TextField"), + ) + } + } + actions() + + validate(valueState.value) + } + + println("BasicTextField 2:") + runUIKitInstrumentedTest { + val requester = FocusRequester() + val state = TextFieldState(initialText, initialSelection) + + setContent { + LaunchedEffect(Unit) { requester.requestFocus() } + Column(modifier = Modifier.safeDrawingPadding()) { + BasicTextField( + state = state, + modifier = Modifier + .fillMaxSize() + .focusRequester(requester) + .testTag("TextField"), + ) + } + } + actions() + waitForIdle() + validate(TextFieldValue(state.text.toString(), state.selection)) + } + } +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt index 619684a6b40b1..bec98daeeebbb 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,6 +36,7 @@ import androidx.compose.ui.test.MockAppDelegate import androidx.compose.ui.test.UIKitInstrumentedTest import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.test.utils.beginPress +import androidx.compose.ui.test.utils.cancel import androidx.compose.ui.test.utils.release import kotlin.test.Test import kotlin.test.assertEquals @@ -45,9 +45,8 @@ import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.readValue import platform.CoreGraphics.CGRectZero import platform.UIKit.UIKeyModifierCommand -import platform.UIKit.UIPasteboard +import platform.UIKit.UIKeyModifierShift import platform.UIKit.UIPress -import platform.UIKit.UIPressType import platform.UIKit.UIPressTypeMenu import platform.UIKit.UIPressTypeSelect import platform.UIKit.UIPressTypeUpArrow @@ -60,10 +59,10 @@ import platform.UIKit.UIWindow class UIPressesEventTest { private class PressTrackingView : UIView(frame = CGRectZero.readValue()) { - val began = mutableListOf() - val changed = mutableListOf() - val ended = mutableListOf() - val cancelled = mutableListOf() + val began = mutableListOf() + val changed = mutableListOf() + val ended = mutableListOf() + val cancelled = mutableListOf() override fun canBecomeFirstResponder(): Boolean = true @@ -98,7 +97,7 @@ class UIPressesEventTest { @Test fun cancelPressDispatchesCancelled() = withPressTrackingView { trackingView, window -> val event = window.beginPress(UIPressTypeSelect) - event.release() + event.cancel() assertEquals(listOf(UIPressTypeSelect), trackingView.began) assertEquals(listOf(UIPressTypeSelect), trackingView.cancelled) @@ -192,38 +191,68 @@ class UIPressesEventTest { ) } - "Hello".forEach { char -> - keystroke(char) - waitForIdle() - } + typeWithKeyboard("Hello") assertEquals("Hello", value) } @Test - fun pasteTextUsingHotkeyIntoBasicTextField() = runUIKitInstrumentedTest { - UIPasteboard.generalPasteboard().string = "Pasted" - + fun testMultipleKeyDownSimultaneously() = runUIKitInstrumentedTest { val requester = FocusRequester() - val state = TextFieldState() + val keyEvents = mutableListOf>() setContent { LaunchedEffect(Unit) { requester.requestFocus() } - BasicTextField( - state = state, + Box( modifier = Modifier .fillMaxSize() - .focusRequester(requester), + .focusRequester(requester) + .focusable() + .onKeyEvent { event -> + keyEvents += event.type to event.key + true + } ) } + fun assertReceived(typeWithEvent: Pair) { + assertEquals(listOf(typeWithEvent), keyEvents) + keyEvents.clear() + } + + // Press Shift + val shiftEvent = beginModifierKeyPress(UIKeyModifierShift) waitForIdle() + assertReceived(KeyEventType.KeyDown to Key.ShiftLeft) - keystroke('v', modifierFlags = UIKeyModifierCommand) - waitUntil("BasicTextField should contain pasteboard text") { - state.text.toString() == "Pasted" - } + // Press Cmd + val cmdEvent = beginModifierKeyPress( + UIKeyModifierCommand, + currentModifiers = UIKeyModifierShift, + ) + waitForIdle() + assertReceived(KeyEventType.KeyDown to Key.MetaLeft) + + // Press 'a' + val aEvent = beginKeyPress('a', modifierFlags = UIKeyModifierShift or UIKeyModifierCommand) + waitForIdle() + assertReceived(KeyEventType.KeyDown to Key.A) + + // Release 'a' + aEvent.release() + waitForIdle() + assertReceived(KeyEventType.KeyUp to Key.A) + + // Release Cmd + cmdEvent.release() + waitForIdle() + assertReceived(KeyEventType.KeyUp to Key.MetaLeft) + + // Release Shift + shiftEvent.release() + waitForIdle() + assertReceived(KeyEventType.KeyUp to Key.ShiftLeft) } } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 458d636dac6da..d9040fb8d0bb1 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.scene.ComposeHostingView import androidx.compose.ui.scene.ComposeHostingViewController import androidx.compose.ui.scene.ComposeLayersViewController import androidx.compose.ui.test.utils.beginKeyPress +import androidx.compose.ui.test.utils.beginModifierKeyPress import androidx.compose.ui.test.utils.beginPress import androidx.compose.ui.test.utils.center import androidx.compose.ui.test.utils.getTouchesEvent @@ -90,6 +91,7 @@ import platform.UIKit.UIInterfaceOrientationPortraitUpsideDown import platform.UIKit.UIScreen import platform.UIKit.UITextInputProtocol import platform.UIKit.UIKeyModifierFlags +import platform.UIKit.UIPressesEvent import platform.UIKit.UIPressType import platform.UIKit.UITouch import platform.UIKit.UIUserInterfaceIdiomPad @@ -410,6 +412,48 @@ internal class UIKitInstrumentedTest( return window.beginKeyPress(char, modifierFlags).release() } + /** + * Simulates pressing a character key on the hardware keyboard and returns the + * in-flight [UIPressesEvent] so the caller can release it later. + * + * @param char The character to press. + * @param modifierFlags The modifier keys held while pressing [char] (e.g. `UIKeyModifierShift`). + * Defaults to no modifiers. + */ + fun beginKeyPress( + char: Char, + modifierFlags: UIKeyModifierFlags = 0, + ): UIPressesEvent { + val window = appDelegate.window() ?: error("No active window in MockAppDelegate") + return window.beginKeyPress(char, modifierFlags) + } + + /** + * Simulates pressing a single modifier key (Shift, Cmd, Alt, Control) down and returns the + * in-flight [UIPressesEvent] so the caller can release it later. + * + * @param newModifierFlags A single [UIKeyModifierFlags] constant (e.g. `UIKeyModifierShift`). + * @param currentModifiers The accumulated modifier state after this key is applied. + * Defaults to [newModifierFlags] itself. + */ + fun beginModifierKeyPress( + newModifierFlags: UIKeyModifierFlags, + currentModifiers: UIKeyModifierFlags = 0, + ): UIPressesEvent { + val window = appDelegate.window() ?: error("No active window in MockAppDelegate") + return window.beginModifierKeyPress(newModifierFlags, currentModifiers) + } + + /** + * Type text using [keystroke] events + * + * @param text to type on keyboard + */ + fun typeWithKeyboard(text: String) { + text.forEach(::keystroke) + waitForIdle() + } + /** * Simulates a tap gesture at the specified position on the screen. * @@ -667,4 +711,19 @@ internal fun UIKitInstrumentedTest.captureScreenshot(): UIImage? { UIGraphicsEndImageContext() return screenshot +} + +internal fun UIKitInstrumentedTest.waitForContextMenu() { + val menuClassName = if (available(OS.Ios to OSVersion(16))) { + "_UIEditMenuContainerView" + } else { + "UICalloutBar" + } + waitForIdle() + waitUntil { + firstNodeOrNull { node -> + node.element?.let { it::class.simpleName } == menuClassName + } != null + } + delay(500) // wait for toolbar animation } \ No newline at end of file diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt index 8b98da2bea7a0..6211e86ccbcf1 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt @@ -16,8 +16,10 @@ package androidx.compose.ui.test.utils +import androidx.compose.test.utils.cancelPress import androidx.compose.test.utils.endPress import androidx.compose.test.utils.keyboardPressEventForCharacter +import androidx.compose.test.utils.keyboardPressEventForModifierKey import androidx.compose.test.utils.pressesEventOfType import kotlinx.cinterop.ExperimentalForeignApi import platform.UIKit.UIKeyModifierFlags @@ -31,6 +33,14 @@ internal fun UIWindow.beginPress(pressType: UIPressType): UIPressesEvent { ?: error("UIPressesEvent unavailable on this runtime") } +/** + * Simulates pressing a character key on the hardware keyboard and returns the + * in-flight [UIPressesEvent] so the caller can [release] it later. + * + * @param char The character to press. + * @param modifierFlags The modifier keys held while pressing [char] (e.g. [UIKeyModifierShift]). + * Defaults to no modifiers. + */ @OptIn(ExperimentalForeignApi::class) internal fun UIWindow.beginKeyPress( char: Char, @@ -43,4 +53,26 @@ internal fun UIWindow.beginKeyPress( ) ?: error("Cannot synthesise a key press for '$char' — unsupported character.") } +/** + * Simulates pressing a single modifier key (Shift, Cmd, Alt, Control) and returns the + * in-flight [UIPressesEvent] so the caller can [release] it later. + * + * @param newModifierFlags A single [UIKeyModifierFlags] constant (e.g. [UIKeyModifierShift]). + * @param currentModifiersFlags The accumulated modifier state **after** this key is pressed. + * Defaults to [newModifierFlags] itself (i.e. only this modifier is held). + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIWindow.beginModifierKeyPress( + newModifierFlags: UIKeyModifierFlags, + currentModifiersFlags: UIKeyModifierFlags, +): UIPressesEvent { + return UIPressesEvent.keyboardPressEventForModifierKey( + newModifierFlags, + currentModifiers = currentModifiersFlags, + inWindow = this, + ) ?: error("Cannot synthesise a modifier key press for flags=$newModifierFlags — unsupported modifier.") +} + internal fun UIPressesEvent.release() = this.endPress() + +internal fun UIPressesEvent.cancel() = this.cancelPress() diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h index 4439ef29cad00..68cd094da9e1b 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h @@ -27,6 +27,10 @@ NS_ASSUME_NONNULL_BEGIN modifierFlags:(UIKeyModifierFlags)modifierFlags inWindow:(UIWindow *)window; ++ (nullable instancetype)keyboardPressEventForModifierKey:(UIKeyModifierFlags)modifierKey + currentModifiers:(UIKeyModifierFlags)currentModifiers + inWindow:(UIWindow *)window; + - (void)pressChanged; - (void)endPress; - (void)cancelPress; diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m index ceb7eefe3452e..2446fa3873355 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m @@ -186,6 +186,17 @@ static void CMPSetIntegerIvar(id obj, NSArray *names, NSInteger valu return key; } +static BOOL CMPHIDKeyCodeForModifier(UIKeyModifierFlags modifierKey, + NSInteger *outKeyCode) { + // Map a single modifier flag to the HID usage code of the left-side key. + if (modifierKey == UIKeyModifierAlphaShift) { *outKeyCode = 57; return YES; } // Caps Lock + if (modifierKey == UIKeyModifierShift) { *outKeyCode = 225; return YES; } // Left Shift + if (modifierKey == UIKeyModifierControl) { *outKeyCode = 224; return YES; } // Left Control + if (modifierKey == UIKeyModifierAlternate) { *outKeyCode = 226; return YES; } // Left Alt + if (modifierKey == UIKeyModifierCommand) { *outKeyCode = 227; return YES; } // Left GUI (Cmd) + return NO; +} + static BOOL CMPHIDKeyCodeForCharacter(unichar c, NSInteger *outKeyCode, NSInteger *outModifierFlags, @@ -301,6 +312,23 @@ + (nullable instancetype)keyboardPressEventForCharacter:(NSString *)character return event; } ++ (nullable instancetype)keyboardPressEventForModifierKey:(UIKeyModifierFlags)modifierKey + currentModifiers:(UIKeyModifierFlags)currentModifiers + inWindow:(UIWindow *)window { + NSInteger keyCode = 0; + if (!CMPHIDKeyCodeForModifier(modifierKey, &keyCode)) { return nil; } + + UIResponder *target = CMPFindFirstResponder(window); + UIPress *press = CMPMakeKeyboardPress(@"", @"", keyCode, (NSInteger)(currentModifiers | modifierKey), window, target); + if (press == nil) { return nil; } + + UIPressesEvent *event = CMPMakePressesEvent(press); + if (event == nil) { return nil; } + + CMPDispatchPresses(event, press, UIPressPhaseBegan); + return event; +} + - (void)pressChanged { UIPress *press = ((UIPressesEvent *)self).allPresses.anyObject; if (press == nil) { return; } From 9bb387e408e265443471669bee40da666aafc1c0 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 19 May 2026 11:51:30 +0200 Subject: [PATCH 06/15] Add text field hotkey tests --- .../ui/platform/UIKitTextInputService.ios.kt | 20 ++++++++++--------- .../ui/scene/ComposeSceneMediator.ios.kt | 5 ++++- .../ui/interaction/TextFieldHotkeyTest.kt | 8 ++++---- .../compose/ui/test/UIKitInstrumentedTest.kt | 13 ++++-------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt index ca8f087eb3c6c..15b20f3aed936 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt @@ -155,10 +155,10 @@ internal class UIKitTextInputService( } (currentInputConnection as? ComposeTextInputConnection)?.showToolbarMenu( rect, - ignoringActiveKeyDown(onCopyRequested), - ignoringActiveKeyDown(onPasteRequested), - ignoringActiveKeyDown(onCutRequested), - ignoringActiveKeyDown(onSelectAllRequested), + ignoringWhenActiveKeyDown(onCopyRequested), + ignoringWhenActiveKeyDown(onPasteRequested), + ignoringWhenActiveKeyDown(onCutRequested), + ignoringWhenActiveKeyDown(onSelectAllRequested), ) } @@ -186,10 +186,10 @@ internal class UIKitTextInputService( customActions: List? ) { (currentInputConnection as? NativeTextInputConnection)?.updateNativeTextInputEditMenuState( - ignoringActiveKeyDown(copy), - ignoringActiveKeyDown(paste), - ignoringActiveKeyDown(cut), - ignoringActiveKeyDown(selectAll), + ignoringWhenActiveKeyDown(copy), + ignoringWhenActiveKeyDown(paste), + ignoringWhenActiveKeyDown(cut), + ignoringWhenActiveKeyDown(selectAll), customActions ) } @@ -201,7 +201,9 @@ internal class UIKitTextInputService( } } - private fun ignoringActiveKeyDown(action: (() -> Unit)?): (() -> Unit)? { + // To prevent double-hotkey processing, prevent any edit actions if any active and handled + // key is down. + private fun ignoringWhenActiveKeyDown(action: (() -> Unit)?): (() -> Unit)? { if (action == null) return null return { if (!hasActiveKeyDown()) { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 9f815cb257769..5931ae3e88e97 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -698,7 +698,10 @@ internal class ComposeSceneMediator( keyboardManager.stop() } - fun focusOverlayViewIfNeeded() { + // The Overlay View needs to be focused to be able to handle keyboard actions. + // In general, the iOS system automatically reassigns the first responder focus to the overlay + // view when other views resign the first responder focus, except at the time of initial appearance. + private fun focusOverlayViewIfNeeded() { if (!isFocusEnabled) { return } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt index d3096b44ad52f..4c2db86208646 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt @@ -18,6 +18,7 @@ package androidx.compose.ui.interaction import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.TextFieldState @@ -33,6 +34,7 @@ import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.test.waitForContextMenu import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp import kotlin.test.Test import kotlin.test.assertEquals import kotlinx.cinterop.ExperimentalForeignApi @@ -140,12 +142,11 @@ class TextFieldHotkeyTest { setContent { LaunchedEffect(Unit) { requester.requestFocus() } - Column(modifier = Modifier.safeDrawingPadding()) { + Column(modifier = Modifier.safeDrawingPadding().padding(30.dp)) { BasicTextField( value = valueState.value, onValueChange = { valueState.value = it }, modifier = Modifier - .fillMaxSize() .focusRequester(requester) .testTag("TextField"), ) @@ -163,11 +164,10 @@ class TextFieldHotkeyTest { setContent { LaunchedEffect(Unit) { requester.requestFocus() } - Column(modifier = Modifier.safeDrawingPadding()) { + Column(modifier = Modifier.safeDrawingPadding().padding(30.dp)) { BasicTextField( state = state, modifier = Modifier - .fillMaxSize() .focusRequester(requester) .testTag("TextField"), ) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index d9040fb8d0bb1..604394bf08f1b 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -381,8 +381,6 @@ internal class UIKitInstrumentedTest( } as UIWindow } - // Presses: - /** * Simulates a button press and release for [pressType]. * @@ -420,10 +418,7 @@ internal class UIKitInstrumentedTest( * @param modifierFlags The modifier keys held while pressing [char] (e.g. `UIKeyModifierShift`). * Defaults to no modifiers. */ - fun beginKeyPress( - char: Char, - modifierFlags: UIKeyModifierFlags = 0, - ): UIPressesEvent { + fun beginKeyPress(char: Char, modifierFlags: UIKeyModifierFlags = 0): UIPressesEvent { val window = appDelegate.window() ?: error("No active window in MockAppDelegate") return window.beginKeyPress(char, modifierFlags) } @@ -432,9 +427,9 @@ internal class UIKitInstrumentedTest( * Simulates pressing a single modifier key (Shift, Cmd, Alt, Control) down and returns the * in-flight [UIPressesEvent] so the caller can release it later. * - * @param newModifierFlags A single [UIKeyModifierFlags] constant (e.g. `UIKeyModifierShift`). - * @param currentModifiers The accumulated modifier state after this key is applied. - * Defaults to [newModifierFlags] itself. + * @param newModifierFlags A new [UIKeyModifierFlags] that will be added to previous modifiers. + * @param currentModifiers The accumulated modifier state before this key is applied. + * Defaults to no flags. */ fun beginModifierKeyPress( newModifierFlags: UIKeyModifierFlags, From a959896cfd99750c521fb9becfee4eda56fec3fc Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 19 May 2026 16:04:35 +0200 Subject: [PATCH 07/15] Add focus lost test --- .../ui/scene/ComposeSceneMediator.ios.kt | 37 ++++++++--- .../compose/ui/window/InputViews.ios.kt | 20 +++--- .../ui/interaction/UIPressesEventTest.kt | 61 +++++++++++++++++-- 3 files changed, 93 insertions(+), 25 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 5931ae3e88e97..4ab6a1d200f0e 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -104,6 +104,7 @@ import kotlinx.cinterop.useContents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.job +import kotlinx.coroutines.launch import org.jetbrains.skiko.OS import org.jetbrains.skiko.OSVersion import org.jetbrains.skiko.available @@ -209,6 +210,8 @@ internal class ComposeSceneMediator( override var isActive by mutableStateOf(false) } + private val coroutineScope = CoroutineScope(coroutineContext) + private val isActive get() = coroutineContext.isActive private val viewConfiguration: ViewConfiguration = @@ -293,7 +296,11 @@ internal class ComposeSceneMediator( onHoverEvent = ::onHoverEvent, onKeyboardPresses = ::onKeyboardPresses, ignoreTouchChanges = navigationEventInput::isBackGestureActive, - onCancelKeyboardPresses = ::onCancelKeyboardPresses, + onViewHierarchyWillChange = { + coroutineScope.launch { + finishUnattachedKeysPresses() + } + }, ) val overlayView: UIView get() = _overlayView @@ -403,7 +410,7 @@ internal class ComposeSceneMediator( private val textInputServiceAdapter by lazy { UIKitTextInputServiceAdapter( textInputService, - CoroutineScope(coroutineContext) + coroutineScope ) } @@ -746,21 +753,33 @@ internal class ComposeSceneMediator( private data class KeyIdentifier( val key: Key, val codePoint: Int, - val modifiers: PointerKeyboardModifiers - ) + val modifiers: PointerKeyboardModifiers, + ) { + var press: UIPress? = null // Should not be part of the identifier + + val isAttachedToWindow: Boolean get() = (press?.responder as? UIView)?.window != null + } private fun KeyEvent.keyIdentifier(): KeyIdentifier { val internalEvent = internal - return KeyIdentifier(internalEvent.key, internalEvent.codePoint, internalEvent.modifiers) + return KeyIdentifier( + key = internalEvent.key, + codePoint = internalEvent.codePoint, + modifiers = internalEvent.modifiers, + ).also { + it.press = internalEvent.nativeEvent as? UIPress + } } private val pressedKeysToHandledState = mutableMapOf() - private fun onCancelKeyboardPresses() { + // iOS does not complete or cancels key events which are attached to a view that is not in + // the window hierarchy. + private fun finishUnattachedKeysPresses() { if (pressedKeysToHandledState.isEmpty()) { return } - pressedKeysToHandledState.toList().reversed().forEach { (key, _) -> + pressedKeysToHandledState.filter { !it.key.isAttachedToWindow }.forEach { (key, _) -> onKeyboardEvent( KeyEvent( key = key.key, @@ -770,7 +789,7 @@ internal class ComposeSceneMediator( isMetaPressed = key.modifiers.isMetaPressed, isAltPressed = key.modifiers.isAltPressed, isShiftPressed = key.modifiers.isShiftPressed, - nativeEvent = null, + nativeEvent = key.press, ) ) } @@ -790,6 +809,8 @@ internal class ComposeSceneMediator( if (pressedKeysToHandledState.contains(identifier)) { pressedKeysToHandledState.remove(identifier) } else { + println(">>>>> CLEAR???") + // Dirty state - remove all events to prevent further errors pressedKeysToHandledState.clear() } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index c5967c6ef9f21..ec62bcc76e815 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -513,8 +513,8 @@ internal class OverlayInputView( onCancelScroll: () -> Unit, private var onHoverEvent: (position: DpOffset, event: UIEvent?, eventKind: TouchesEventKind) -> Unit, private var onKeyboardPresses: (Set<*>) -> Unit, - private var onCancelKeyboardPresses: () -> Unit, ignoreTouchChanges: () -> Boolean, + private var onViewHierarchyWillChange: () -> Unit, ) : CMPScrollView(CGRectZero.readValue()) { /** * Gesture recognizer responsible for processing touches @@ -577,19 +577,15 @@ internal class OverlayInputView( scrollsToTop = false } - override fun canBecomeFirstResponder() = true - - override fun canBecomeFocused(): Boolean = false + override fun willRemoveSubview(subview: UIView) { + super.willRemoveSubview(subview) - override fun resignFirstResponder(): Boolean { - onCancelKeyboardPresses() - return super.resignFirstResponder() + onViewHierarchyWillChange() } - override fun becomeFirstResponder(): Boolean { - onCancelKeyboardPresses() - return super.becomeFirstResponder() - } + override fun canBecomeFirstResponder() = true + + override fun canBecomeFocused(): Boolean = false override fun pressesBegan(presses: Set<*>, withEvent: UIPressesEvent?) { onKeyboardPresses(presses) @@ -732,7 +728,7 @@ internal class OverlayInputView( onOutsidePointerEvent = {} onTouchesEvent = { _, _, _ -> PointerEventResult() } onCancelAllTouches = {} - onCancelKeyboardPresses = {} + onViewHierarchyWillChange = {} trackedTouchesOutside.clear() } } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt index bec98daeeebbb..ae15291333084 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt @@ -18,6 +18,7 @@ package androidx.compose.ui.interaction import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.LaunchedEffect @@ -31,6 +32,7 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.test.MockAppDelegate import androidx.compose.ui.test.UIKitInstrumentedTest @@ -196,6 +198,55 @@ class UIPressesEventTest { assertEquals("Hello", value) } + @Test + fun simulateInterruptedKeyPressEvent() = runUIKitInstrumentedTest { + val requester1 = FocusRequester() + val requester2 = FocusRequester() + val keyEvents = mutableListOf>() + + setContent { + LaunchedEffect(Unit) { + requester1.requestFocus() + } + Column( + modifier = Modifier.focusable().onPreviewKeyEvent { event -> + keyEvents += event.type to event.key + true + } + ) { + BasicTextField( + value = "", + onValueChange = {}, + modifier = Modifier.focusRequester(requester1) + ) + BasicTextField( + value = "", + onValueChange = {}, + modifier = Modifier.focusRequester(requester2) + ) + } + } + + // Press 'x' and verify key down is dispatched to the onKeyEvent modifier + val xPress = beginKeyPress('x') + waitForIdle() + assertEquals(listOf(KeyEventType.KeyDown to Key.X), keyEvents) + keyEvents.clear() + + // Remove focus from text field while 'x' is still held down + requester2.requestFocus() + waitForIdle() + + // Key up must be received when focus is removed (press is cancelled by UIKit) + assertEquals(listOf(KeyEventType.KeyUp to Key.X), keyEvents) + keyEvents.clear() + + // Release the physical key — no additional event should arrive + xPress.release() + waitForIdle() + assertTrue(keyEvents.isEmpty(), "Expected no additional key events after physical release, got: $keyEvents") + } + @Test fun testMultipleKeyDownSimultaneously() = runUIKitInstrumentedTest { val requester = FocusRequester() @@ -240,16 +291,16 @@ class UIPressesEventTest { waitForIdle() assertReceived(KeyEventType.KeyDown to Key.A) - // Release 'a' - aEvent.release() - waitForIdle() - assertReceived(KeyEventType.KeyUp to Key.A) - // Release Cmd cmdEvent.release() waitForIdle() assertReceived(KeyEventType.KeyUp to Key.MetaLeft) + // Release 'a' + aEvent.release() + waitForIdle() + assertReceived(KeyEventType.KeyUp to Key.A) + // Release Shift shiftEvent.release() waitForIdle() From 9f14cad5f8e356c24f78b138f921573c17b473bf Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 19 May 2026 16:04:54 +0200 Subject: [PATCH 08/15] Add focus lost test --- .../kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 4ab6a1d200f0e..f398ac674dd0c 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -809,7 +809,6 @@ internal class ComposeSceneMediator( if (pressedKeysToHandledState.contains(identifier)) { pressedKeysToHandledState.remove(identifier) } else { - println(">>>>> CLEAR???") // Dirty state - remove all events to prevent further errors pressedKeysToHandledState.clear() } From 425dc59b40c3bc0c0e3147339cd6007409cf1ea7 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Wed, 20 May 2026 15:52:28 +0200 Subject: [PATCH 09/15] Refactor text input to use shortcuts for system actions from the UITextInput. --- .../foundation/text/ContextMenu.ios.kt | 82 +++++++++++-------- .../compose/foundation/text/KeyMapping.ios.kt | 35 ++++++++ .../selection/TextFieldSelectionState.ios.kt | 8 +- .../TextFieldSelectionManager.ios.kt | 4 + .../foundation/text/KeyMapping.macos.kt} | 4 +- .../CMPUIKitUtils/CMPEditMenuView.h | 5 ++ .../CMPUIKitUtils/CMPEditMenuView.m | 41 +++++++++- .../ui/platform/UIKitTextInputService.ios.kt | 27 +----- .../ui/scene/ComposeSceneMediator.ios.kt | 15 ++-- .../input/ComposeTextInputConnection.ios.kt | 44 ++++++---- .../input/NativeTextInputConnection.ios.kt | 7 +- .../ui/text/input/TextInputConnection.ios.kt | 9 ++ .../compose/ui/window/InputViews.ios.kt | 2 +- ...ssesEventTest.kt => KeyboardEventsTest.kt} | 38 ++++++++- 14 files changed, 224 insertions(+), 97 deletions(-) create mode 100644 compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/KeyMapping.ios.kt rename compose/foundation/foundation/src/{darwinMain/kotlin/androidx/compose/foundation/text/KeyMapping.darwin.kt => macosMain/kotlin/androidx/compose/foundation/text/KeyMapping.macos.kt} (89%) rename compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/{UIPressesEventTest.kt => KeyboardEventsTest.kt} (91%) diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenu.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenu.ios.kt index 8b3fd79af41a5..a0889996e743f 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenu.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenu.ios.kt @@ -496,15 +496,18 @@ private fun notifyAboutContextMenuItems( @Composable private fun startObservingSelectionChanges( context: UIKitNativeTextInputContext, - selectionProvider: () -> TextRange, - onSelectionChanged: () -> Unit + itemsStateProvider: () -> ContextMenuItemsState, ) { - LaunchedEffect(selectionProvider) { - snapshotFlow { if (context.usingNativeTextInput()) selectionProvider() else null } - .filterNotNull() - .collect { - onSelectionChanged() - } + LaunchedEffect(itemsStateProvider) { + snapshotFlow { itemsStateProvider() }.collect { + context.updateNativeTextInputEditMenuState( + copy = it.copy, + paste = it.paste, + cut = it.cut, + selectAll = it.selectAll, + customActions = it.customActions + ) + } } } @@ -521,24 +524,37 @@ private fun startNotifyingAboutContextMenuItems( manager: TextFieldSelectionManager, nativeTextInputContext: UIKitNativeTextInputContext, ) { + val scope = rememberCoroutineScope() startObservingSelectionChanges( nativeTextInputContext, - selectionProvider = { manager.value.selection }, - onSelectionChanged = { - nativeTextInputContext.updateNativeTextInputEditMenuState( - copy = if (manager.isCopyAllowed()) ({ manager.copy(cancelSelection = false) }) else null, - paste = if (manager.canShowPasteMenuItem()) ({ manager.paste() }) else null, - cut = if (manager.canShowCutMenuItem()) ({ manager.cut() }) else null, - selectAll = if (manager.canShowSelectAllMenuItem()) ({ manager.selectAll() }) else null, + itemsStateProvider = { + fun editBlock(isEnabled: Boolean, action: () -> Unit): (() -> Unit)? { + return if (isEnabled) { + { + action() + scope.launch { + manager.updateClipboardEntry() + } + } + } else { + null + } + } + ContextMenuItemsState( + copy = editBlock(manager.isCopyAllowed()) { manager.copy(cancelSelection = false) }, + paste = editBlock(manager.canShowPasteMenuItem()) { manager.paste() }, + cut = editBlock(manager.canShowCutMenuItem()) { manager.cut() }, + selectAll = editBlock(manager.canShowSelectAllMenuItem()) { manager.selectAll() }, customActions = emptyList() ) - }) + } + ) } /** * Starts notifying the native iOS input system about the available context menu items (isNewContextMenu = false) in [BasicTextField] (with [TextFieldState] argument) * - * @param selectionState The current state of the text field selection, including selection bounds + * @param state The current state of the text field selection, including selection bounds * and related actions. * @param nativeTextInputContext The UIKitNativeTextInputContext instance used to update the edit menu state * with actions. @@ -546,30 +562,32 @@ private fun startNotifyingAboutContextMenuItems( @OptIn(InternalComposeUiApi::class) @Composable private fun startNotifyingAboutContextMenuItems( - selectionState: TextFieldSelectionState, + state: TextFieldSelectionState, nativeTextInputContext: UIKitNativeTextInputContext, ) { // this should be the same scope as at the root of BasicTextField val coroutineScope = rememberCoroutineScope() startObservingSelectionChanges( nativeTextInputContext, - selectionProvider = { selectionState.textFieldState.visualText.selection }, - onSelectionChanged = { - val copyBlock: () -> Unit = - { coroutineScope.launch { selectionState.copy(cancelSelection = false) } } - val pasteBlock: () -> Unit = { coroutineScope.launch { selectionState.paste() } } - val cutBlock: () -> Unit = { coroutineScope.launch { selectionState.cut() } } - val selectAllBlock: () -> Unit = { - coroutineScope.launch { - selectionState.selectAll() + itemsStateProvider = { + fun editBlock(isEnabled: Boolean, action: suspend () -> Unit): (() -> Unit)? { + return if (isEnabled) { + { + coroutineScope.launch { + action() + state.updateClipboardEntry() + } + } + } else { + null } } - nativeTextInputContext.updateNativeTextInputEditMenuState( - copy = if (selectionState.canShowCopyMenuItem()) (copyBlock) else null, - paste = if (selectionState.canShowPasteMenuItem()) (pasteBlock) else null, - cut = if (selectionState.canShowCutMenuItem()) (cutBlock) else null, - selectAll = if (selectionState.canShowSelectAllMenuItem()) (selectAllBlock) else null, + ContextMenuItemsState( + copy = editBlock(state.canShowCopyMenuItem()) { state.copy(cancelSelection = false) }, + paste = editBlock(state.canShowPasteMenuItem()) { state.paste() }, + cut = editBlock(state.canShowCutMenuItem()) { state.cut() }, + selectAll = editBlock(state.canShowSelectAllMenuItem()) { state.selectAll() }, customActions = emptyList() ) } diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/KeyMapping.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/KeyMapping.ios.kt new file mode 100644 index 0000000000000..10a78ec271828 --- /dev/null +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/KeyMapping.ios.kt @@ -0,0 +1,35 @@ +/* + * 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.foundation.text + +import androidx.compose.ui.input.key.KeyEvent +import org.jetbrains.skiko.OS + +internal actual val platformDefaultKeyMapping: KeyMapping = createPlatformDefaultKeyMapping() + +internal fun createPlatformDefaultKeyMapping(): KeyMapping { + val keyMapping = createMacOsDefaultKeyMapping() + return object : KeyMapping { + override fun map(event: KeyEvent): KeyCommand? { + return when (val command = keyMapping.map(event)) { + // UITextInput is used to handle clipboard events + KeyCommand.COPY, KeyCommand.CUT, KeyCommand.PASTE, KeyCommand.SELECT_ALL -> null + else -> command + } + } + } +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.ios.kt index c876a9b8c8cfe..925fc82ce4442 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.ios.kt @@ -43,6 +43,9 @@ import androidx.compose.foundation.text.input.internal.selection.TextToolbarStat import androidx.compose.foundation.text.selection.MouseSelectionObserver import androidx.compose.foundation.text.selection.SelectionAdjustment import androidx.compose.foundation.text.selection.awaitSelectionGestures +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.isSpecified @@ -331,8 +334,8 @@ private class UIKitTextFieldTextDragObserver( } internal actual class ClipboardPasteState actual constructor(private val clipboard: Clipboard) { - private var _hasClip = false - private var _hasText = false + private var _hasClip by mutableStateOf(false) + private var _hasText by mutableStateOf(false) actual val hasText: Boolean get() = _hasText actual val hasClip: Boolean get() = _hasClip @@ -362,6 +365,7 @@ internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { onClick() close() + state.updateClipboardEntry() } }) ) diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.ios.kt index 6ccf6a8af7598..99b6e2300d65c 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.ios.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import kotlin.math.max import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch internal actual fun Modifier.textFieldMagnifier( manager: TextFieldSelectionManager @@ -183,6 +184,9 @@ internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( onClick = { onClick() close() + coroutineScope.launch { + manager.updateClipboardEntry() + } }) ) } diff --git a/compose/foundation/foundation/src/darwinMain/kotlin/androidx/compose/foundation/text/KeyMapping.darwin.kt b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/KeyMapping.macos.kt similarity index 89% rename from compose/foundation/foundation/src/darwinMain/kotlin/androidx/compose/foundation/text/KeyMapping.darwin.kt rename to compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/KeyMapping.macos.kt index 3864ccbd610c4..7c7f5fff3a4c6 100644 --- a/compose/foundation/foundation/src/darwinMain/kotlin/androidx/compose/foundation/text/KeyMapping.darwin.kt +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/KeyMapping.macos.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * 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. @@ -16,4 +16,4 @@ package androidx.compose.foundation.text -internal actual val platformDefaultKeyMapping: KeyMapping = createMacOsDefaultKeyMapping() +internal actual val platformDefaultKeyMapping: KeyMapping = createMacOsDefaultKeyMapping() \ No newline at end of file diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h index e8e913957a4ff..5878c3fc2fa62 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h @@ -34,6 +34,11 @@ selectAll:(void (^)(void))selectAllBlock customActions:(NSArray *)customActions; +- (void)updateAvailableSystemActions:(void (^)(void))copyBlock + cut:(void (^)(void))cutBlock + paste:(void (^)(void))pasteBlock + selectAll:(void (^)(void))selectAllBlock; + - (void)hideEditMenu; - (NSTimeInterval)editMenuDelay; diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m index a4560b9c1cf56..1f0d8a61026aa 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m @@ -63,12 +63,19 @@ @interface CMPEditMenuView() @property (weak, nonatomic, nullable) UIView *rootView; +// Used for context menu @property (copy, nonatomic, nullable) void (^copyBlock)(void); @property (copy, nonatomic, nullable) void (^cutBlock)(void); @property (copy, nonatomic, nullable) void (^pasteBlock)(void); @property (copy, nonatomic, nullable) void (^selectAllBlock)(void); @property (copy, nonatomic, nullable) NSArray *customActions; +// Used for hotkeys and other operations +@property (copy, nonatomic, nullable) void (^systemCopyBlock)(void); +@property (copy, nonatomic, nullable) void (^systemCutBlock)(void); +@property (copy, nonatomic, nullable) void (^systemPasteBlock)(void); +@property (copy, nonatomic, nullable) void (^systemSelectAllBlock)(void); + @property (strong, nonatomic, nullable) dispatch_block_t showContextMenuBlock; @property (strong, nonatomic, nullable) dispatch_block_t presentInteractionBlock; @@ -141,6 +148,16 @@ - (void)showEditMenuAtRect:(CGRect)targetRect } } +- (void)updateAvailableSystemActions:(void (^)(void))copyBlock + cut:(void (^)(void))cutBlock + paste:(void (^)(void))pasteBlock + selectAll:(void (^)(void))selectAllBlock { + self.systemCopyBlock = copyBlock; + self.systemCutBlock = cutBlock; + self.systemPasteBlock = pasteBlock; + self.systemSelectAllBlock = selectAllBlock; +} + - (void)didMoveToWindow { [super didMoveToWindow]; @@ -308,16 +325,16 @@ - (BOOL)contextMenuItemsChangedCopy:(void (^)(void))copyBlock - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { if (@selector(copy:) == action) { - return self.copyBlock != nil; + return self.copyBlock != nil || self.systemCopyBlock != nil; } if (@selector(paste:) == action) { - return self.pasteBlock != nil; + return self.pasteBlock != nil || self.systemCutBlock != nil; } if (@selector(cut:) == action) { - return self.cutBlock != nil; + return self.cutBlock != nil || self.systemPasteBlock != nil; } if (@selector(selectAll:) == action) { - return self.selectAllBlock != nil; + return self.selectAllBlock != nil || self.systemSelectAllBlock != nil; } if (@selector(customAction0:) == action) return self.customActions.count > 0; @@ -337,24 +354,32 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { - (void)copy:(id)sender { if (self.copyBlock != nil) { self.copyBlock(); + } else if (self.systemCopyBlock != nil) { + self.systemCopyBlock(); } } - (void)paste:(id)sender { if (self.pasteBlock != nil) { self.pasteBlock(); + } else if (self.systemCutBlock != nil) { + self.systemCutBlock(); } } - (void)cut:(id)sender { if (self.cutBlock != nil) { self.cutBlock(); + } else if (self.systemPasteBlock != nil) { + self.systemPasteBlock(); } } - (void)selectAll:(id)sender { if (self.selectAllBlock != nil) { self.selectAllBlock(); + } else if (self.systemSelectAllBlock != nil) { + self.systemSelectAllBlock(); } } @@ -445,4 +470,12 @@ - (UIMenu *)editMenuInteraction:(UIEditMenuInteraction *)interaction return [UIMenu menuWithTitle:@"" children:allActions]; } +- (UIView *)inputView { + return nil; +} + +- (UIView *)inputAccessoryView { + return nil; +} + @end diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt index 15b20f3aed936..04991a86fcee2 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt @@ -47,7 +47,6 @@ internal class UIKitTextInputService( private val focusedViewsList: FocusedViewsList?, private var onInputStarted: () -> Unit, private var focusManager: () -> ComposeSceneFocusManager?, - private var hasActiveKeyDown: () -> Boolean, coroutineContext: CoroutineContext ) { @@ -154,11 +153,7 @@ internal class UIKitTextInputService( ) } (currentInputConnection as? ComposeTextInputConnection)?.showToolbarMenu( - rect, - ignoringWhenActiveKeyDown(onCopyRequested), - ignoringWhenActiveKeyDown(onPasteRequested), - ignoringWhenActiveKeyDown(onCutRequested), - ignoringWhenActiveKeyDown(onSelectAllRequested), + rect, onCopyRequested, onPasteRequested, onCutRequested, onSelectAllRequested ) } @@ -185,12 +180,8 @@ internal class UIKitTextInputService( selectAll: (() -> Unit)?, customActions: List? ) { - (currentInputConnection as? NativeTextInputConnection)?.updateNativeTextInputEditMenuState( - ignoringWhenActiveKeyDown(copy), - ignoringWhenActiveKeyDown(paste), - ignoringWhenActiveKeyDown(cut), - ignoringWhenActiveKeyDown(selectAll), - customActions + currentInputConnection?.updateNativeTextInputEditMenuState( + copy, paste, cut, selectAll, customActions ) } @@ -201,21 +192,9 @@ internal class UIKitTextInputService( } } - // To prevent double-hotkey processing, prevent any edit actions if any active and handled - // key is down. - private fun ignoringWhenActiveKeyDown(action: (() -> Unit)?): (() -> Unit)? { - if (action == null) return null - return { - if (!hasActiveKeyDown()) { - action() - } - } - } - fun dispose() { stopInput() onInputStarted = { } focusManager = { null } - hasActiveKeyDown = { false } } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index f398ac674dd0c..c269fd80dce1b 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -400,7 +400,6 @@ internal class ComposeSceneMediator( focusedViewsList = focusedViewsList, onInputStarted = { animateKeyboardOffsetChanges = true }, focusManager = { scene.focusManager }, - hasActiveKeyDown = { pressedKeysToHandledState.values.contains(true) }, coroutineContext = coroutineContext, ).also { KeyboardVisibilityListener.initialize() @@ -771,15 +770,15 @@ internal class ComposeSceneMediator( } } - private val pressedKeysToHandledState = mutableMapOf() + private val pressedKeysState = mutableListOf() // iOS does not complete or cancels key events which are attached to a view that is not in // the window hierarchy. private fun finishUnattachedKeysPresses() { - if (pressedKeysToHandledState.isEmpty()) { + if (pressedKeysState.isEmpty()) { return } - pressedKeysToHandledState.filter { !it.key.isAttachedToWindow }.forEach { (key, _) -> + pressedKeysState.filter { !it.isAttachedToWindow }.forEach { key -> onKeyboardEvent( KeyEvent( key = key.key, @@ -804,13 +803,13 @@ internal class ComposeSceneMediator( val identifier = keyEvent.keyIdentifier() if (keyEvent.type == KeyEventType.KeyDown) { - pressedKeysToHandledState[identifier] = result + pressedKeysState.add(identifier) } else if (keyEvent.type == KeyEventType.KeyUp) { - if (pressedKeysToHandledState.contains(identifier)) { - pressedKeysToHandledState.remove(identifier) + if (pressedKeysState.contains(identifier)) { + pressedKeysState.removeAll { it == identifier } } else { // Dirty state - remove all events to prevent further errors - pressedKeysToHandledState.clear() + pressedKeysState.clear() } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt index 4f136fd68a3f3..7f064a8363fd7 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.text.input import androidx.compose.ui.geometry.Rect import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.TextToolbarStatus +import androidx.compose.ui.platform.UIKitNativeTextInputContextMenuCustomAction import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.scene.ComposeSceneFocusManager import androidx.compose.ui.uikit.density @@ -86,6 +87,7 @@ internal open class ComposeTextInputConnection( view.removeFromSuperview() } } + textInputView.updateAvailableSystemActions(null, null, null, null) } override fun stateWillChange(textChanged: Boolean, selectionChanged: Boolean) { @@ -112,6 +114,16 @@ internal open class ComposeTextInputConnection( showMenuOrUpdatePosition() } + override fun updateNativeTextInputEditMenuState( + copy: (() -> Unit)?, + paste: (() -> Unit)?, + cut: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List? + ) { + textInputView.updateAvailableSystemActions(copy, paste, cut, selectAll) + } + val toolbarStatus: TextToolbarStatus get() = if (textInputView.isTextMenuShown()) { TextToolbarStatus.Shown @@ -127,29 +139,25 @@ internal open class ComposeTextInputConnection( onSelectAllRequested: (() -> Unit)? ) { showMenuOrUpdatePosition = { - textInputView.let { textInputView -> - val density = view.density - val offset = textInputView.frame.useContents { origin.toDpOffset().toOffset(density) } - val target = rect.translate(-offset).toDpRect(density).toCGRect() - textInputView.showEditMenuAtRect( - targetRect = target, - copy = onCopyRequested, - cut = onCutRequested, - paste = onPasteRequested, - selectAll = onSelectAllRequested, - customActions = emptyList() - ) - textMenuAppearanceChanged() - } + val density = view.density + val offset = textInputView.frame.useContents { origin.toDpOffset().toOffset(density) } + val target = rect.translate(-offset).toDpRect(density).toCGRect() + textInputView.showEditMenuAtRect( + targetRect = target, + copy = onCopyRequested, + cut = onCutRequested, + paste = onPasteRequested, + selectAll = onSelectAllRequested, + customActions = emptyList() + ) + textMenuAppearanceChanged() } showMenuOrUpdatePosition() } fun hideToolbar() { showMenuOrUpdatePosition = {} - textInputView.let { - it.hideTextMenu() - textMenuAppearanceChanged() - } + textInputView.hideTextMenu() + textMenuAppearanceChanged() } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt index 3e8bb53fdf972..951433253a0d1 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt @@ -362,13 +362,10 @@ internal class NativeTextInputConnection( // If not specified, iOS would use the default system tint color private var selectionTintColor: Color? = null private fun setupTintColor() { - textInputView.let { - val uiColor = selectionTintColor?.toUIColor() - it.setTintColor(uiColor) - } + textInputView.setTintColor(selectionTintColor?.toUIColor()) } - fun updateNativeTextInputEditMenuState( + override fun updateNativeTextInputEditMenuState( copy: (() -> Unit)?, paste: (() -> Unit)?, cut: (() -> Unit)?, 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 dbb3d1497d367..5a844d471d6eb 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.platform.EmptyInputTraits import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.SkikoUITextInputTraits import androidx.compose.ui.platform.TextEditingDelegate +import androidx.compose.ui.platform.UIKitNativeTextInputContextMenuCustomAction import androidx.compose.ui.platform.getUITextInputTraits import androidx.compose.ui.scene.ComposeSceneFocusManager import androidx.compose.ui.text.TextRange @@ -134,6 +135,14 @@ internal abstract class TextInputConnection( } } + abstract fun updateNativeTextInputEditMenuState( + copy: (() -> Unit)?, + paste: (() -> Unit)?, + cut: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List? + ) + /** * Workaround to prevent IME action from being called multiple times with hardware keyboards. * When the hardware return key is held down, iOS sends multiple newline characters to the application, diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index ec62bcc76e815..e52a05f3f1cf9 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -514,7 +514,7 @@ internal class OverlayInputView( private var onHoverEvent: (position: DpOffset, event: UIEvent?, eventKind: TouchesEventKind) -> Unit, private var onKeyboardPresses: (Set<*>) -> Unit, ignoreTouchChanges: () -> Boolean, - private var onViewHierarchyWillChange: () -> Unit, + private var onViewHierarchyWillChange: () -> Unit = {}, ) : CMPScrollView(CGRectZero.readValue()) { /** * Gesture recognizer responsible for processing touches diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/KeyboardEventsTest.kt similarity index 91% rename from compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt rename to compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/KeyboardEventsTest.kt index ae15291333084..194e0e13aa1bb 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/UIPressesEventTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/KeyboardEventsTest.kt @@ -40,12 +40,15 @@ import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.test.utils.beginPress import androidx.compose.ui.test.utils.cancel import androidx.compose.ui.test.utils.release +import androidx.compose.ui.viewinterop.UIKitView import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import kotlinx.cinterop.COpaquePointer import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.readValue import platform.CoreGraphics.CGRectZero +import platform.Foundation.NSStringFromSelector import platform.UIKit.UIKeyModifierCommand import platform.UIKit.UIKeyModifierShift import platform.UIKit.UIPress @@ -58,7 +61,7 @@ import platform.UIKit.UIViewController import platform.UIKit.UIWindow @OptIn(ExperimentalForeignApi::class) -class UIPressesEventTest { +class KeyboardEventsTest { private class PressTrackingView : UIView(frame = CGRectZero.readValue()) { val began = mutableListOf() @@ -306,4 +309,37 @@ class UIPressesEventTest { waitForIdle() assertReceived(KeyEventType.KeyUp to Key.ShiftLeft) } + + @Test + fun copyEventPropagatedToNativeView() = runUIKitInstrumentedTest { + val view = FocusableView() + + setContent { + UIKitView( + factory = { view }, + modifier = Modifier.fillMaxSize() + ) + } + + view.becomeFirstResponder() + + keystroke('c', modifierFlags = UIKeyModifierCommand) + waitForIdle() + + assertTrue(view.isCopyCalled) + } +} + +@OptIn(ExperimentalForeignApi::class) +private class FocusableView: UIView(frame = CGRectZero.readValue()) { + var isCopyCalled = false + + override fun canBecomeFirstResponder(): Boolean = true + + override fun canPerformAction(action: COpaquePointer?, withSender: Any?): Boolean = + NSStringFromSelector(action) == "copy:" + + override fun copy(sender: Any?) { + isCopyCalled = true + } } From 0ae715e66f9ad8f53102c5a2c7d22e39787d26a5 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 26 May 2026 08:55:08 +0200 Subject: [PATCH 10/15] Fix tests --- .../foundation/text/ContextMenu.ios.kt | 6 ++ .../CMPUIKitUtils/CMPEditMenuView.m | 8 +-- .../ui/platform/UIKitTextInputService.ios.kt | 16 +++++ .../input/ComposeTextInputConnection.ios.kt | 3 +- .../compose/ui/window/InputViews.ios.kt | 6 +- .../ui/interaction/TextFieldHotkeyTest.kt | 11 ++-- .../objc/CMPTestUtils/UIPressesEvent+Test.m | 62 ++++++++++++++++--- 7 files changed, 90 insertions(+), 22 deletions(-) diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenu.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenu.ios.kt index a0889996e743f..634d19badcf98 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenu.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenu.ios.kt @@ -524,6 +524,9 @@ private fun startNotifyingAboutContextMenuItems( manager: TextFieldSelectionManager, nativeTextInputContext: UIKitNativeTextInputContext, ) { + LaunchedEffect(manager) { + manager.updateClipboardEntry() + } val scope = rememberCoroutineScope() startObservingSelectionChanges( nativeTextInputContext, @@ -565,6 +568,9 @@ private fun startNotifyingAboutContextMenuItems( state: TextFieldSelectionState, nativeTextInputContext: UIKitNativeTextInputContext, ) { + LaunchedEffect(state) { + state.updateClipboardEntry() + } // this should be the same scope as at the root of BasicTextField val coroutineScope = rememberCoroutineScope() startObservingSelectionChanges( diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m index 1f0d8a61026aa..7d1895eb65b32 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m @@ -362,16 +362,16 @@ - (void)copy:(id)sender { - (void)paste:(id)sender { if (self.pasteBlock != nil) { self.pasteBlock(); - } else if (self.systemCutBlock != nil) { - self.systemCutBlock(); + } else if (self.systemPasteBlock != nil) { + self.systemPasteBlock(); } } - (void)cut:(id)sender { if (self.cutBlock != nil) { self.cutBlock(); - } else if (self.systemPasteBlock != nil) { - self.systemPasteBlock(); + } else if (self.systemCutBlock != nil) { + self.systemCutBlock(); } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt index 04991a86fcee2..0d87a10163637 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt @@ -54,6 +54,16 @@ internal class UIKitTextInputService( private var currentInputConnection: TextInputConnection? by mutableStateOf(null) + private data class EditMenuState( + val copy: (() -> Unit)?, + val paste: (() -> Unit)?, + val cut: (() -> Unit)?, + val selectAll: (() -> Unit)?, + val customActions: List? + ) + + private var lastEditMenuState: EditMenuState? = null + val hasInvalidations: Boolean get() = currentInputConnection?.hasInvalidations ?: false @@ -108,6 +118,11 @@ internal class UIKitTextInputService( ) } currentInputConnection?.start(request) + lastEditMenuState?.let { state -> + currentInputConnection?.updateNativeTextInputEditMenuState( + state.copy, state.paste, state.cut, state.selectAll, state.customActions + ) + } onInputStarted() } @@ -180,6 +195,7 @@ internal class UIKitTextInputService( selectAll: (() -> Unit)?, customActions: List? ) { + lastEditMenuState = EditMenuState(copy, paste, cut, selectAll, customActions) currentInputConnection?.updateNativeTextInputEditMenuState( copy, paste, cut, selectAll, customActions ) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt index 7f064a8363fd7..5ec8bfd33c10f 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt @@ -17,7 +17,6 @@ package androidx.compose.ui.text.input import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.TextToolbarStatus import androidx.compose.ui.platform.UIKitNativeTextInputContextMenuCustomAction import androidx.compose.ui.platform.ViewConfiguration @@ -121,7 +120,7 @@ internal open class ComposeTextInputConnection( selectAll: (() -> Unit)?, customActions: List? ) { - textInputView.updateAvailableSystemActions(copy, paste, cut, selectAll) + textInputView.updateAvailableSystemActions(copy, cut, paste, selectAll) } val toolbarStatus: TextToolbarStatus diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index e52a05f3f1cf9..8b9c1ccf18bb5 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -514,7 +514,7 @@ internal class OverlayInputView( private var onHoverEvent: (position: DpOffset, event: UIEvent?, eventKind: TouchesEventKind) -> Unit, private var onKeyboardPresses: (Set<*>) -> Unit, ignoreTouchChanges: () -> Boolean, - private var onViewHierarchyWillChange: () -> Unit = {}, + private var onViewHierarchyWillChange: (() -> Unit)?, ) : CMPScrollView(CGRectZero.readValue()) { /** * Gesture recognizer responsible for processing touches @@ -580,7 +580,7 @@ internal class OverlayInputView( override fun willRemoveSubview(subview: UIView) { super.willRemoveSubview(subview) - onViewHierarchyWillChange() + onViewHierarchyWillChange?.invoke() } override fun canBecomeFirstResponder() = true @@ -728,7 +728,7 @@ internal class OverlayInputView( onOutsidePointerEvent = {} onTouchesEvent = { _, _, _ -> PointerEventResult() } onCancelAllTouches = {} - onViewHierarchyWillChange = {} + onViewHierarchyWillChange = null trackedTouchesOutside.clear() } } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt index 4c2db86208646..88146a7f6ecc9 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt @@ -84,14 +84,13 @@ class TextFieldHotkeyTest { @Test fun pasteClipboardAtCursor() = runTestsWithTextField( - initialText = "Hello", - initialSelection = TextRange(5), + initialText = "Hello ", + initialSelection = TextRange(6), actions = { - UIPasteboard.generalPasteboard().string = " World" keystroke('v', modifierFlags = UIKeyModifierCommand) }, validate = { value -> - assertEquals("Hello World", value.text) + assertEquals("Hello Kotlin", value.text) } ) @@ -100,7 +99,6 @@ class TextFieldHotkeyTest { initialText = "Hello World", initialSelection = TextRange(6, 11), actions = { - UIPasteboard.generalPasteboard().string = "Kotlin" keystroke('v', modifierFlags = UIKeyModifierCommand) }, validate = { value -> @@ -120,7 +118,6 @@ class TextFieldHotkeyTest { // Replace the selected word with clipboard content via the keyboard shortcut, // without touching any menu button. - UIPasteboard.generalPasteboard().string = "Kotlin" keystroke('v', modifierFlags = UIKeyModifierCommand) }, validate = { value -> @@ -139,6 +136,7 @@ class TextFieldHotkeyTest { runUIKitInstrumentedTest { val requester = FocusRequester() val valueState = mutableStateOf(TextFieldValue(initialText, initialSelection)) + UIPasteboard.generalPasteboard().string = "Kotlin" setContent { LaunchedEffect(Unit) { requester.requestFocus() } @@ -161,6 +159,7 @@ class TextFieldHotkeyTest { runUIKitInstrumentedTest { val requester = FocusRequester() val state = TextFieldState(initialText, initialSelection) + UIPasteboard.generalPasteboard().string = "Kotlin" setContent { LaunchedEffect(Unit) { requester.requestFocus() } diff --git a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m index 2446fa3873355..bba2fc6eca858 100644 --- a/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m @@ -128,6 +128,52 @@ static id CMPSharedEventEnvironment(void) { return press; } +#pragma mark - Keyboard shortcut action dispatch + +static SEL CMPStandardActionForShortcut(NSString *unmodifiedCharacter, + UIKeyModifierFlags modifierFlags) { + if (modifierFlags == UIKeyModifierCommand) { + if ([unmodifiedCharacter isEqualToString:@"c"]) { return @selector(copy:); } + if ([unmodifiedCharacter isEqualToString:@"v"]) { return @selector(paste:); } + if ([unmodifiedCharacter isEqualToString:@"x"]) { return @selector(cut:); } + if ([unmodifiedCharacter isEqualToString:@"a"]) { return @selector(selectAll:); } + if ([unmodifiedCharacter isEqualToString:@"z"]) { return @selector(undo:); } + } + if (modifierFlags == (UIKeyModifierCommand | UIKeyModifierShift)) { + if ([unmodifiedCharacter isEqualToString:@"z"]) { return @selector(redo:); } + } + return NULL; +} + +// Mirrors UIKit's internal shortcut-to-action routing that fires automatically on real hardware +// but is skipped when using synthetic UIPressesEvents. +static void CMPFireKeyCommandAction(UIResponder *firstResponder, + NSString *unmodifiedCharacter, + UIKeyModifierFlags modifierFlags) { + // Walk responder chain for explicit UIKeyCommands registered via -keyCommands. + UIResponder *responder = firstResponder; + while (responder != nil) { + for (UIKeyCommand *cmd in [responder keyCommands]) { + if ([cmd.input caseInsensitiveCompare:unmodifiedCharacter] == NSOrderedSame && + cmd.modifierFlags == modifierFlags && cmd.action != nil) { + [[UIApplication sharedApplication] sendAction:cmd.action + to:responder + from:nil + forEvent:nil]; + return; + } + } + responder = responder.nextResponder; + } + + // Fall back to standard system shortcuts (copy:, paste:, cut:, etc.) that UIKit + // handles automatically on real hardware regardless of registered UIKeyCommands. + SEL action = CMPStandardActionForShortcut(unmodifiedCharacter, modifierFlags); + if (action != NULL) { + [[UIApplication sharedApplication] sendAction:action to:nil from:nil forEvent:nil]; + } +} + #pragma mark - Dispatch static void CMPDispatchPresses(UIEvent *event, @@ -294,19 +340,21 @@ + (nullable instancetype)keyboardPressEventForCharacter:(NSString *)character CMPDispatchPresses(event, press, UIPressPhaseBegan); - // UIKit's hardware-key→text pipeline (`insertText:` on a focused - // `UIKeyInput`) doesn't fire reliably for synthetic events. Drive that - // final hop ourselves so a focused TextField actually receives the typed - // character; the press dispatch above still runs the standard responder - // hooks for tests that observe pressesBegan/Ended. - // Skip when a command/control/alt modifier is set — those are keyboard - // shortcuts (e.g. ⌘V), not text insertion. + // UIKit's hardware-key pipelines don't fire reliably for synthetic events: + // • text insertion: `insertText:` on a focused `UIKeyInput` + // • shortcut actions: UIKeyCommand / built-in actions (copy:, paste:, …) + // Drive these final hops ourselves so tests observe the same behaviour as + // a real hardware keyboard; the press dispatch above still runs the standard + // responder hooks (pressesBegan/Ended) for tests that need them. UIKeyModifierFlags shortcutModifiers = UIKeyModifierCommand | UIKeyModifierControl | UIKeyModifierAlternate; BOOL isShortcut = (extraModifierFlags & shortcutModifiers) != 0; if (!isShortcut && target != nil && [target respondsToSelector:@selector(insertText:)]) { [(id)target insertText:character]; + } else if (isShortcut) { + CMPFireKeyCommandAction(target, unmodifiedCharacters ?: character, + (UIKeyModifierFlags)modifierFlags); } return event; From 0a516ac3e5389ec808e15220c69ad62e01260b69 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 26 May 2026 09:57:36 +0200 Subject: [PATCH 11/15] Fix tests --- .../CMPUIKitUtils/CMPEditMenuView.m | 4 +- .../ui/platform/UIKitTextInputService.ios.kt | 39 ++++++++++--------- .../input/ComposeTextInputConnection.ios.kt | 7 +++- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m index 7d1895eb65b32..0530d0860ddd3 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m @@ -328,10 +328,10 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { return self.copyBlock != nil || self.systemCopyBlock != nil; } if (@selector(paste:) == action) { - return self.pasteBlock != nil || self.systemCutBlock != nil; + return self.pasteBlock != nil || self.systemPasteBlock != nil; } if (@selector(cut:) == action) { - return self.cutBlock != nil || self.systemPasteBlock != nil; + return self.cutBlock != nil || self.systemCutBlock != nil; } if (@selector(selectAll:) == action) { return self.selectAllBlock != nil || self.systemSelectAllBlock != nil; diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt index 0d87a10163637..5dd96f5e641c0 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt @@ -54,15 +54,7 @@ internal class UIKitTextInputService( private var currentInputConnection: TextInputConnection? by mutableStateOf(null) - private data class EditMenuState( - val copy: (() -> Unit)?, - val paste: (() -> Unit)?, - val cut: (() -> Unit)?, - val selectAll: (() -> Unit)?, - val customActions: List? - ) - - private var lastEditMenuState: EditMenuState? = null + private var updateEditMenuState = {} val hasInvalidations: Boolean get() = currentInputConnection?.hasInvalidations ?: false @@ -118,12 +110,7 @@ internal class UIKitTextInputService( ) } currentInputConnection?.start(request) - lastEditMenuState?.let { state -> - currentInputConnection?.updateNativeTextInputEditMenuState( - state.copy, state.paste, state.cut, state.selectAll, state.customActions - ) - } - + updateEditMenuState() onInputStarted() } @@ -195,10 +182,24 @@ internal class UIKitTextInputService( selectAll: (() -> Unit)?, customActions: List? ) { - lastEditMenuState = EditMenuState(copy, paste, cut, selectAll, customActions) - currentInputConnection?.updateNativeTextInputEditMenuState( - copy, paste, cut, selectAll, customActions - ) + fun update() { + currentInputConnection?.updateNativeTextInputEditMenuState( + copy = copy, + paste = paste, + cut = cut, + selectAll = selectAll, + customActions = customActions + ) + updateEditMenuState = {} + } + + if (currentInputConnection == null) { + // Fixes race conditions when the `updateNativeTextInputEditMenuState` called before + // the input session start. + updateEditMenuState = ::update + } else { + update() + } } override fun updateNativeTextInputTintColor(color: Color?) { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt index 5ec8bfd33c10f..4f25da4f49e7b 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt @@ -120,7 +120,12 @@ internal open class ComposeTextInputConnection( selectAll: (() -> Unit)?, customActions: List? ) { - textInputView.updateAvailableSystemActions(copy, cut, paste, selectAll) + textInputView.updateAvailableSystemActions( + copyBlock = copy, + cut = cut, + paste = paste, + selectAll = selectAll + ) } val toolbarStatus: TextToolbarStatus From d06774f51f154791c1c5477d8d21226ac964107d Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 26 May 2026 10:02:55 +0200 Subject: [PATCH 12/15] Update labels --- .../compose/ui/platform/UIKitTextInputService.ios.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt index 64db5d7dc8a63..ed01751e08e8d 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt @@ -155,7 +155,11 @@ internal class UIKitTextInputService( ) } (currentInputConnection as? ComposeTextInputConnection)?.showToolbarMenu( - rect, onCopyRequested, onPasteRequested, onCutRequested, onSelectAllRequested + rect = rect, + onCopyRequested = onCopyRequested, + onPasteRequested = onPasteRequested, + onCutRequested = onCutRequested, + onSelectAllRequested = onSelectAllRequested ) } From 295e30f51b75d3e194a4ef6e7d1e55cc06f33b21 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 26 May 2026 10:12:25 +0200 Subject: [PATCH 13/15] Fix text input service after merge --- .../androidx/compose/ui/platform/UIKitTextInputService.ios.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt index ed01751e08e8d..7e4539290e1bf 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt @@ -41,7 +41,7 @@ import platform.UIKit.UIView @OptIn(ExperimentalComposeUiApi::class) internal class UIKitTextInputService( - private val updateView: () -> Unit, + private var updateView: () -> Unit, private val view: UIView, private val viewConfiguration: ViewConfiguration, private val focusedViewsList: FocusedViewsList?, @@ -217,7 +217,7 @@ internal class UIKitTextInputService( fun dispose() { stopInput() onInputStarted = { } - onKeyboardPresses = { } + updateView = {} focusManager = { null } } } From afea31f2ee4dc322209f8cf3e7a2090077ff09f6 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 26 May 2026 10:56:16 +0200 Subject: [PATCH 14/15] Fix tests --- .../compose/ui/platform/UIKitTextInputService.ios.kt | 3 +++ .../androidx/compose/ui/scene/ComposeSceneMediator.ios.kt | 8 +------- .../kotlin/androidx/compose/ui/window/InputViews.ios.kt | 8 -------- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt index 7e4539290e1bf..fc34612de8d55 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt @@ -46,6 +46,7 @@ internal class UIKitTextInputService( private val viewConfiguration: ViewConfiguration, private val focusedViewsList: FocusedViewsList?, private var onInputStarted: () -> Unit, + private var onInputStopped: () -> Unit, private var focusManager: () -> ComposeSceneFocusManager?, coroutineContext: CoroutineContext ) { @@ -117,6 +118,7 @@ internal class UIKitTextInputService( private fun stopInput() { currentInputConnection?.stop() currentInputConnection = null + onInputStopped() } fun showSoftwareKeyboard() { @@ -217,6 +219,7 @@ internal class UIKitTextInputService( fun dispose() { stopInput() onInputStarted = { } + onInputStopped = {} updateView = {} focusManager = { null } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 753b5dd3146b0..39c34ec832201 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -33,7 +33,6 @@ import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.hapticfeedback.CupertinoHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.input.InputMode -import androidx.compose.ui.input.InputModeManager import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType @@ -106,7 +105,6 @@ import kotlinx.cinterop.useContents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.job -import kotlinx.coroutines.launch import org.jetbrains.skiko.OS import org.jetbrains.skiko.OSVersion import org.jetbrains.skiko.available @@ -298,11 +296,6 @@ internal class ComposeSceneMediator( onHoverEvent = ::onHoverEvent, onKeyboardPresses = ::onKeyboardPresses, ignoreTouchChanges = navigationEventInput::isBackGestureActive, - onViewHierarchyWillChange = { - coroutineScope.launch { - finishUnattachedKeysPresses() - } - }, ) val overlayView: UIView get() = _overlayView @@ -401,6 +394,7 @@ internal class ComposeSceneMediator( viewConfiguration = viewConfiguration, focusedViewsList = focusedViewsList, onInputStarted = { animateKeyboardOffsetChanges = true }, + onInputStopped = ::finishUnattachedKeysPresses, focusManager = { scene.focusManager }, coroutineContext = coroutineContext, ).also { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index 8b9c1ccf18bb5..40df3d3379a4b 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -514,7 +514,6 @@ internal class OverlayInputView( private var onHoverEvent: (position: DpOffset, event: UIEvent?, eventKind: TouchesEventKind) -> Unit, private var onKeyboardPresses: (Set<*>) -> Unit, ignoreTouchChanges: () -> Boolean, - private var onViewHierarchyWillChange: (() -> Unit)?, ) : CMPScrollView(CGRectZero.readValue()) { /** * Gesture recognizer responsible for processing touches @@ -577,12 +576,6 @@ internal class OverlayInputView( scrollsToTop = false } - override fun willRemoveSubview(subview: UIView) { - super.willRemoveSubview(subview) - - onViewHierarchyWillChange?.invoke() - } - override fun canBecomeFirstResponder() = true override fun canBecomeFocused(): Boolean = false @@ -728,7 +721,6 @@ internal class OverlayInputView( onOutsidePointerEvent = {} onTouchesEvent = { _, _, _ -> PointerEventResult() } onCancelAllTouches = {} - onViewHierarchyWillChange = null trackedTouchesOutside.clear() } } From 09de5c036cff6794bce17df27a3f89a1bb885425 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 26 May 2026 11:10:50 +0200 Subject: [PATCH 15/15] Fix keyboard events processing --- .../ui/platform/UIKitTextInputService.ios.kt | 47 +++++++------------ .../ui/scene/ComposeSceneMediator.ios.kt | 1 + 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt index fc34612de8d55..199d8019a3153 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.ios.kt @@ -47,6 +47,11 @@ internal class UIKitTextInputService( private val focusedViewsList: FocusedViewsList?, private var onInputStarted: () -> Unit, private var onInputStopped: () -> Unit, + /** + * Callback to handle keyboard presses. The parameter is a [Set] of [UIPress] objects. + * Erasure happens due to K/N not supporting Obj-C lightweight generics. + */ + private var onKeyboardPresses: (Set<*>) -> Unit, private var focusManager: () -> ComposeSceneFocusManager?, coroutineContext: CoroutineContext ) { @@ -55,8 +60,6 @@ internal class UIKitTextInputService( private var currentInputConnection: TextInputConnection? by mutableStateOf(null) - private var updateEditMenuState = {} - val hasInvalidations: Boolean get() = currentInputConnection?.hasInvalidations ?: false @@ -98,6 +101,7 @@ internal class UIKitTextInputService( view = view, coroutineScope = coroutineScope, focusedViewsList = focusedViewsList, + onKeyboardPresses = onKeyboardPresses, focusManager = focusManager ) } else { @@ -107,11 +111,12 @@ internal class UIKitTextInputService( coroutineScope = coroutineScope, viewConfiguration = viewConfiguration, focusedViewsList = focusedViewsList, + onKeyboardPresses = onKeyboardPresses, focusManager = focusManager ) } currentInputConnection?.start(request) - updateEditMenuState() + onInputStarted() } @@ -135,7 +140,7 @@ internal class UIKitTextInputService( val textToolbar: TextToolbar by lazy(LazyThreadSafetyMode.NONE) { object : TextToolbar { override val status: TextToolbarStatus - get() = (currentInputConnection as? ComposeTextInputConnection)?.toolbarStatus ?: TextToolbarStatus.Hidden + get() = (currentInputConnection as? TextToolbar)?.status ?: TextToolbarStatus.Hidden override fun showMenu( rect: Rect, @@ -156,17 +161,13 @@ internal class UIKitTextInputService( view, coroutineScope, viewConfiguration, focusManager ) } - (currentInputConnection as? ComposeTextInputConnection)?.showToolbarMenu( - rect = rect, - onCopyRequested = onCopyRequested, - onPasteRequested = onPasteRequested, - onCutRequested = onCutRequested, - onSelectAllRequested = onSelectAllRequested + (currentInputConnection as? TextToolbar)?.showMenu( + rect, onCopyRequested, onPasteRequested, onCutRequested, onSelectAllRequested ) } override fun hide() { - (currentInputConnection as? ComposeTextInputConnection)?.hideToolbar() + (currentInputConnection as? TextToolbar)?.hide() if (currentInputConnection is SelectionContainerConnection) { // stop() removes the view from the hierarchy and resigns first responder, @@ -189,24 +190,9 @@ internal class UIKitTextInputService( selectAll: (() -> Unit)?, customActions: List? ) { - fun update() { - currentInputConnection?.updateNativeTextInputEditMenuState( - copy = copy, - paste = paste, - cut = cut, - selectAll = selectAll, - customActions = customActions - ) - updateEditMenuState = {} - } - - if (currentInputConnection == null) { - // Fixes race conditions when the `updateNativeTextInputEditMenuState` called before - // the input session start. - updateEditMenuState = ::update - } else { - update() - } + (currentInputConnection as? NativeTextInputConnection)?.updateNativeTextInputEditMenuState( + copy, paste, cut, selectAll, customActions + ) } override fun updateNativeTextInputTintColor(color: Color?) { @@ -218,8 +204,9 @@ internal class UIKitTextInputService( fun dispose() { stopInput() - onInputStarted = { } + onInputStarted = {} onInputStopped = {} + onKeyboardPresses = {} updateView = {} focusManager = { null } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 39c34ec832201..eb6d164446efc 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -395,6 +395,7 @@ internal class ComposeSceneMediator( focusedViewsList = focusedViewsList, onInputStarted = { animateKeyboardOffsetChanges = true }, onInputStopped = ::finishUnattachedKeysPresses, + onKeyboardPresses = ::onKeyboardPresses, focusManager = { scene.focusManager }, coroutineContext = coroutineContext, ).also {