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..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 @@ -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,40 @@ private fun startNotifyingAboutContextMenuItems( manager: TextFieldSelectionManager, nativeTextInputContext: UIKitNativeTextInputContext, ) { + LaunchedEffect(manager) { + manager.updateClipboardEntry() + } + 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 +565,35 @@ private fun startNotifyingAboutContextMenuItems( @OptIn(InternalComposeUiApi::class) @Composable private fun startNotifyingAboutContextMenuItems( - selectionState: TextFieldSelectionState, + state: TextFieldSelectionState, nativeTextInputContext: UIKitNativeTextInputContext, ) { + LaunchedEffect(state) { + state.updateClipboardEntry() + } // 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 be5bab646ec72..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 @@ -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]; @@ -286,6 +303,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 @@ -302,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.systemPasteBlock != nil; } if (@selector(cut:) == action) { - return self.cutBlock != nil; + return self.cutBlock != nil || self.systemCutBlock != 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; @@ -331,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.systemPasteBlock != nil) { + self.systemPasteBlock(); } } - (void)cut:(id)sender { if (self.cutBlock != nil) { self.cutBlock(); + } else if (self.systemCutBlock != nil) { + self.systemCutBlock(); } } - (void)selectAll:(id)sender { if (self.selectAllBlock != nil) { self.selectAllBlock(); + } else if (self.systemSelectAllBlock != nil) { + self.systemSelectAllBlock(); } } @@ -439,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/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/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 dcb66f14e2c02..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 @@ -37,16 +37,16 @@ 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) internal class UIKitTextInputService( - private val updateView: () -> Unit, + private var updateView: () -> Unit, private val view: UIView, private val viewConfiguration: ViewConfiguration, 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. @@ -123,6 +123,7 @@ internal class UIKitTextInputService( private fun stopInput() { currentInputConnection?.stop() currentInputConnection = null + onInputStopped() } fun showSoftwareKeyboard() { @@ -203,8 +204,10 @@ internal class UIKitTextInputService( fun dispose() { stopInput() - onInputStarted = { } - onKeyboardPresses = { } + onInputStarted = {} + onInputStopped = {} + onKeyboardPresses = {} + updateView = {} focusManager = { null } } } 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 ff20d4a7d7481..d2c5d77b68904 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 @@ -261,7 +261,7 @@ internal class ComposeContainer( architectureComponentsOwner.navigationEventDispatcher.addInput(navigationEventInput) lifecycleDelegate.windowScene = windowScene navigationEventInput.onDidMoveToWindow(view.window, view) - onAccessibilityChanged() + onFocusConditionsChanged() } fun disposeComposeScene() { @@ -303,14 +303,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, @@ -319,7 +319,7 @@ internal class ComposeContainer( ) layersHolder.getLayersViewController().attach(layer) - onAccessibilityChanged() + onFocusConditionsChanged() return layer } @@ -346,15 +346,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 ee39792e2e3f9..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 @@ -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 @@ -67,6 +66,13 @@ import androidx.compose.ui.uikit.LocalUIView import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.uikit.density import androidx.compose.ui.uikit.toNanoSeconds +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 @@ -204,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 = @@ -287,7 +295,7 @@ internal class ComposeSceneMediator( onCancelScroll = ::onCancelScroll, onHoverEvent = ::onHoverEvent, onKeyboardPresses = ::onKeyboardPresses, - ignoreTouchChanges = navigationEventInput::isBackGestureActive + ignoreTouchChanges = navigationEventInput::isBackGestureActive, ) val overlayView: UIView get() = _overlayView @@ -297,6 +305,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, @@ -351,7 +360,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( @@ -376,9 +394,10 @@ internal class ComposeSceneMediator( viewConfiguration = viewConfiguration, focusedViewsList = focusedViewsList, onInputStarted = { animateKeyboardOffsetChanges = true }, + onInputStopped = ::finishUnattachedKeysPresses, onKeyboardPresses = ::onKeyboardPresses, focusManager = { scene.focusManager }, - coroutineContext = coroutineContext + coroutineContext = coroutineContext, ).also { KeyboardVisibilityListener.initialize() } @@ -387,7 +406,7 @@ internal class ComposeSceneMediator( private val textInputServiceAdapter by lazy { UIKitTextInputServiceAdapter( textInputService, - CoroutineScope(coroutineContext) + coroutineScope ) } @@ -682,6 +701,32 @@ internal class ComposeSceneMediator( keyboardManager.stop() } + // 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 + } + 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)? @@ -701,13 +746,73 @@ 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, + ) { + 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( + key = internalEvent.key, + codePoint = internalEvent.codePoint, + modifiers = internalEvent.modifiers, + ).also { + it.press = internalEvent.nativeEvent as? UIPress + } + } + + 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 (pressedKeysState.isEmpty()) { + return + } + pressedKeysState.filter { !it.isAttachedToWindow }.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 = key.press, + ) + ) + } + } + + 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) { + pressedKeysState.add(identifier) + } else if (keyEvent.type == KeyEventType.KeyUp) { + if (pressedKeysState.contains(identifier)) { + pressedKeysState.removeAll { it == identifier } + } else { + // Dirty state - remove all events to prevent further errors + pressedKeysState.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/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/text/input/ComposeTextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt index ea1766b1cf410..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 @@ -17,8 +17,8 @@ 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 @@ -44,16 +44,14 @@ 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. private var showMenuOrUpdatePosition = {} @@ -88,6 +86,7 @@ internal open class ComposeTextInputConnection( view.removeFromSuperview() } } + textInputView.updateAvailableSystemActions(null, null, null, null) } override fun stateWillChange(textChanged: Boolean, selectionChanged: Boolean) { @@ -114,14 +113,29 @@ internal open class ComposeTextInputConnection( showMenuOrUpdatePosition() } - override val status: TextToolbarStatus + override fun updateNativeTextInputEditMenuState( + copy: (() -> Unit)?, + paste: (() -> Unit)?, + cut: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List? + ) { + textInputView.updateAvailableSystemActions( + copyBlock = copy, + cut = cut, + paste = paste, + selectAll = selectAll + ) + } + + val toolbarStatus: TextToolbarStatus get() = if (textInputView.isTextMenuShown()) { TextToolbarStatus.Shown } else { TextToolbarStatus.Hidden } - override fun showMenu( + fun showToolbarMenu( rect: Rect, onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, @@ -129,30 +143,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() } - - override fun hide() { + 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 e9746d092cb8b..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 @@ -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() } @@ -364,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/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..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 @@ -51,7 +52,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 { @@ -135,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/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/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index fd681691a8057..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 @@ -590,6 +590,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 @@ -726,6 +731,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 +769,9 @@ internal class BackgroundInputView( override fun didMoveToWindow() { super.didMoveToWindow() + window?.let { + onMovedToWindow() + } setNeedsLayout() } @@ -800,6 +809,7 @@ internal class BackgroundInputView( removeGestureRecognizer(touchesGestureRecognizer) touchesGestureRecognizer.dispose() + onMovedToWindow = {} hitTestInteropView = { null } isPointInsideInteractionBounds = { false } onLayoutSubviews = {} 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 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/KeyboardEventsTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/KeyboardEventsTest.kt new file mode 100644 index 0000000000000..194e0e13aa1bb --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/KeyboardEventsTest.kt @@ -0,0 +1,345 @@ +/* + * 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.Column +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.onPreviewKeyEvent +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.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 +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 KeyboardEventsTest { + + 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.cancel() + + 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), + ) + } + + typeWithKeyboard("Hello") + + 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() + val keyEvents = mutableListOf>() + + setContent { + LaunchedEffect(Unit) { + requester.requestFocus() + } + Box( + modifier = Modifier + .fillMaxSize() + .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) + + // 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 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() + 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 + } +} 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..88146a7f6ecc9 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldHotkeyTest.kt @@ -0,0 +1,180 @@ +/* + * 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.padding +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 androidx.compose.ui.unit.dp +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(6), + actions = { + keystroke('v', modifierFlags = UIKeyModifierCommand) + }, + validate = { value -> + assertEquals("Hello Kotlin", value.text) + } + ) + + @Test + fun pasteClipboardReplacesSelection() = runTestsWithTextField( + initialText = "Hello World", + initialSelection = TextRange(6, 11), + actions = { + 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. + 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)) + UIPasteboard.generalPasteboard().string = "Kotlin" + + setContent { + LaunchedEffect(Unit) { requester.requestFocus() } + Column(modifier = Modifier.safeDrawingPadding().padding(30.dp)) { + BasicTextField( + value = valueState.value, + onValueChange = { valueState.value = it }, + modifier = Modifier + .focusRequester(requester) + .testTag("TextField"), + ) + } + } + actions() + + validate(valueState.value) + } + + println("BasicTextField 2:") + runUIKitInstrumentedTest { + val requester = FocusRequester() + val state = TextFieldState(initialText, initialSelection) + UIPasteboard.generalPasteboard().string = "Kotlin" + + setContent { + LaunchedEffect(Unit) { requester.requestFocus() } + Column(modifier = Modifier.safeDrawingPadding().padding(30.dp)) { + BasicTextField( + state = state, + modifier = Modifier + .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/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 5bbd551a4bc06..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 @@ -22,10 +22,14 @@ 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.beginModifierKeyPress +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 +90,9 @@ import platform.UIKit.UIInterfaceOrientationPortrait 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 import platform.UIKit.UIView @@ -374,6 +381,74 @@ internal class UIKitInstrumentedTest( } as UIWindow } + /** + * 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 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 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 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, + 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. * @@ -631,4 +706,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 new file mode 100644 index 0000000000000..6211e86ccbcf1 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UIPressesEvent+Utils.kt @@ -0,0 +1,78 @@ +/* + * 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.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 +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") +} + +/** + * 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, + modifierFlags: UIKeyModifierFlags = 0, +): UIPressesEvent { + return UIPressesEvent.keyboardPressEventForCharacter( + char.toString(), + modifierFlags = modifierFlags, + inWindow = this, + ) ?: 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.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..68cd094da9e1b --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.h @@ -0,0 +1,40 @@ +/* + * 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 + modifierFlags:(UIKeyModifierFlags)modifierFlags + inWindow:(UIWindow *)window; + ++ (nullable instancetype)keyboardPressEventForModifierKey:(UIKeyModifierFlags)modifierKey + currentModifiers:(UIKeyModifierFlags)currentModifiers + 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..bba2fc6eca858 --- /dev/null +++ b/testutils/testutils-xctest/src/iosMain/objc/CMPTestUtils/UIPressesEvent+Test.m @@ -0,0 +1,398 @@ +/* + * 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 - 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, + 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 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, + 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 + modifierFlags:(UIKeyModifierFlags)extraModifierFlags + 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; + } + modifierFlags |= (NSInteger)extraModifierFlags; + + 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 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; +} + ++ (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; } + 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