From c26c37242daf26a1ab49575c4e7c47915f6bb457 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 31 Aug 2026 17:52:16 +0200 Subject: [PATCH 01/12] Support iOS "Save Password" autofill Support "Save Password" when the username and password views are both in the hierarchy at once. - Introduce `TextInputContainer` (a per-scene factory) with a nested `TextInputContainer.Holder` that creates and controls the state and position of the native text input view for every text field in the Compose scene, including unfocused ones. The scene now creates the input view as soon as the corresponding text field appears on screen, and that view becomes the first responder to handle native keyboard input. - Rework `ProvideNewContextMenuDefaultProviders` in `ContextMenu.ios.kt`: it now reuses the corresponding native input views added by `TextInputContainer`. - `ComposeTextInputConnection` and `NativeTextInputConnection` can now work with both active and inactive text input delegates. - Add `PlatformTextInputMethodRequest.editorToken` to connect an editing request with the corresponding native text input view. - Update `CMPEditMenuView` and `CMPTextInputView` to conform to the `UITextField` interface when secure text input is enabled. --- .../foundation/text/BasicTextField.desktop.kt | 8 + .../foundation/text/CoreTextField.desktop.kt | 8 + .../foundation/text/BasicTextField.ios.kt | 288 ++++++++ .../foundation/text/ContextMenu.ios.kt | 641 +++++++----------- .../foundation/text/ContextMenuNode.ios.kt | 135 ---- .../foundation/text/CoreTextField.ios.kt | 230 ++++++- .../foundation/text/TextInputHolder.ios.kt | 80 +++ .../internal/TextFieldCoreModifier.ios.kt | 19 +- .../text/selection/SelectionContainer.ios.kt | 174 +++++ .../text/selection/SelectionHandles.ios.kt | 6 +- .../foundation/text/BasicTextField.macos.kt} | 6 +- .../foundation/text/CoreTextField.macos.kt | 8 + ...cyPlatformTextInputServiceAdapter.skiko.kt | 9 +- .../input/internal/TextInputSession.skiko.kt | 6 +- .../foundation/text/BasicTextField.web.kt} | 14 +- .../foundation/text/CoreTextField.web.kt | 8 + ...sExample.ios.kt => IosSpecificFeatures.kt} | 3 +- .../AutoSafeLoginPasswordExample.ios.kt | 304 +++++++++ .../CMPUIKitObjcUtils/CMPEditMenuView.h | 3 + .../CMPUIKitObjcUtils/CMPEditMenuView.m | 67 +- .../CMPUIKitObjcUtils/CMPTextInputView.h | 2 + .../CMPUIKitObjcUtils/CMPTextInputView.m | 66 +- .../ui/platform/NativeTextInputContext.ios.kt | 24 - .../TextEditingDelegateAdapter.ios.kt | 92 +++ .../ui/platform/TextInputContainer.ios.kt | 143 ++++ .../ui/platform/TextInputHelpers.ios.kt | 59 +- .../ui/platform/TextInputService.ios.kt | 303 ++++++--- .../ui/scene/ComposeSceneMediator.ios.kt | 9 +- .../input/ComposeTextInputConnection.ios.kt | 63 +- .../input/NativeTextInputConnection.ios.kt | 106 +-- .../input/SelectionContainerConnection.ios.kt | 15 +- .../ui/text/input/TextInputConnection.ios.kt | 76 +-- .../ui/uikit/UIKitCompositionLocals.ios.kt | 28 +- .../ui/window/ComposeTextInputView.ios.kt | 16 +- .../ui/window/NativeTextInputView.ios.kt | 5 +- .../PlatformTextInputMethodRequest.skiko.kt | 8 + .../ui/interaction/KeyboardEventsTest.kt | 41 +- .../NestedComposeTextFieldFocusTest.kt | 2 + .../ui/interaction/TextFieldEditMenuTest.kt | 40 +- .../ui/interaction/TextFieldMagnifierTest.kt | 16 +- .../compose/ui/keyboard/ImeOptionsTest.kt | 22 +- .../ui/keyboard/PasswordAutofillTest.kt | 282 ++++++++ .../compose/ui/leaks/MemoryLeaksTest.kt | 25 +- .../compose/ui/test/UIKitInstrumentedTest.kt | 16 +- 44 files changed, 2496 insertions(+), 980 deletions(-) create mode 100644 compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt delete mode 100644 compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenuNode.ios.kt create mode 100644 compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextInputHolder.ios.kt create mode 100644 compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/SelectionContainer.ios.kt rename compose/foundation/foundation/src/{skikoMain/kotlin/androidx/compose/foundation/text/BasicTextField.skiko.kt => macosMain/kotlin/androidx/compose/foundation/text/BasicTextField.macos.kt} (92%) rename compose/foundation/foundation/src/{skikoMain/kotlin/androidx/compose/foundation/text/CoreTextField.skiko.kt => webMain/kotlin/androidx/compose/foundation/text/BasicTextField.web.kt} (71%) rename compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/{IosSpecificFeaturesExample.ios.kt => IosSpecificFeatures.kt} (96%) create mode 100644 compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSafeLoginPasswordExample.ios.kt create mode 100644 compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextEditingDelegateAdapter.ios.kt create mode 100644 compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputContainer.ios.kt create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/BasicTextField.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/BasicTextField.desktop.kt index 11577fa60c6e8..fbd187b6e546f 100644 --- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/BasicTextField.desktop.kt +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/BasicTextField.desktop.kt @@ -18,7 +18,10 @@ package androidx.compose.foundation.text import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.internal.TransformedTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue @@ -320,3 +323,8 @@ fun BasicTextField( ) } +internal actual fun Modifier.textFieldOverlay( + transformedState: TransformedTextFieldState, + keyboardOptions: KeyboardOptions, + interactionSource: InteractionSource +): Modifier = this \ No newline at end of file diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/CoreTextField.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/CoreTextField.desktop.kt index 25340a3366a3f..77d0d78efb5c2 100644 --- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/CoreTextField.desktop.kt +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/CoreTextField.desktop.kt @@ -16,8 +16,10 @@ package androidx.compose.foundation.text +import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.input.ImeOptions import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue @@ -34,3 +36,9 @@ internal actual fun Modifier.textFieldDraw( value: TextFieldValue, offsetMapping: OffsetMapping, ): Modifier = defaultTextFieldDraw(state, value, offsetMapping) + +internal actual fun Modifier.textFieldOverlay( + state: LegacyTextFieldState, + imeOptions: ImeOptions, + interactionSource: InteractionSource? +): Modifier = this \ No newline at end of file diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt new file mode 100644 index 0000000000000..788f3b9347f3c --- /dev/null +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt @@ -0,0 +1,288 @@ +/* + * 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.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.text.input.setSelectionCoerced +import androidx.compose.foundation.text.input.internal.TransformedTextFieldState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.GlobalPositionAwareModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.observeReads +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.TextInputContainer +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.uikit.LocalTextInputContainer +import androidx.compose.ui.unit.Density +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +internal actual fun Modifier.textFieldOverlay( + transformedState: TransformedTextFieldState, + keyboardOptions: KeyboardOptions, + interactionSource: InteractionSource +): Modifier = this then BasicTextFieldImeOverlayElement(transformedState, keyboardOptions, interactionSource) + +private data class BasicTextFieldImeOverlayElement( + private val transformedState: TransformedTextFieldState, + private val keyboardOptions: KeyboardOptions, + private val interactionSource: InteractionSource, +) : ModifierNodeElement() { + + override fun create() = BasicTextFieldImeOverlayNode(transformedState, keyboardOptions, interactionSource) + + override fun update(node: BasicTextFieldImeOverlayNode) { + node.update(transformedState, keyboardOptions, interactionSource) + } + + override fun InspectorInfo.inspectableProperties() { + name = "basicTextFieldImeOverlay" + properties["transformedState"] = transformedState + properties["keyboardOptions"] = keyboardOptions + properties["interactionSource"] = interactionSource + } +} + +@OptIn(InternalComposeUiApi::class) +private class BasicTextFieldImeOverlayNode( + private var transformedState: TransformedTextFieldState, + keyboardOptions: KeyboardOptions, + private var interactionSource: InteractionSource, +) : Modifier.Node(), + CompositionLocalConsumerModifierNode, + GlobalPositionAwareModifierNode, + ObserverModifierNode { + + private val delegate = BasicTextFieldInputDelegate( + transformedState = transformedState, + imeOptions = keyboardOptions.toImeOptions() + ) + + private var container: TextInputContainer? = null + + private var holder: TextInputContainer.Holder? = null + set(value) { + field = value + transformedState.holder = value + } + + private var bounds: Rect? = null + + private var density: Density? = null + + private var focusObserverJob: Job? = null + + private var stateObserverJob: Job? = null + + override fun onAttach() { + onObservedReadsChanged() + observeFocus() + observeMirroredState() + } + + override fun onDetach() { + removeTextInput() + container = null + bounds = null + density = null + } + + fun update( + transformedState: TransformedTextFieldState, + keyboardOptions: KeyboardOptions, + interactionSource: InteractionSource, + ) { + if (this.transformedState !== transformedState) { + // The platform text input belongs to the text field rather than to the state object it + // happens to be backed by, so hand the holder over instead of recreating it. + this.transformedState.holder = null + this.transformedState = transformedState + transformedState.holder = holder + delegate.transformedState = transformedState + observeMirroredState() + } + delegate.imeOptions = keyboardOptions.toImeOptions() + + if (this.interactionSource != interactionSource) { + this.interactionSource = interactionSource + observeFocus() + } + } + + override fun onObservedReadsChanged() { + var container: TextInputContainer? = null + var density: Density? = null + observeReads { + container = currentValueOf(LocalTextInputContainer) + density = currentValueOf(LocalDensity) + } + + if (container != this.container) { + removeTextInput() + this.container = container + this.density = density + createTextInput() + } else if (density != this.density) { + this.density = density + updateRect() + } + } + + override fun onGloballyPositioned(coordinates: LayoutCoordinates) { + bounds = coordinates.boundsInRoot() + updateRect() + } + + private fun createTextInput() { + removeTextInput() + holder = container?.createTextInput(delegate) + updateRect() + } + + private fun removeTextInput() { + holder?.remove() + holder = null + } + + private fun updateRect() { + val holder = holder ?: return + val bounds = bounds ?: return + holder.setRect(bounds) + } + + private fun observeMirroredState() { + stateObserverJob?.cancel() + stateObserverJob = coroutineScope.launch { + snapshotFlow { transformedState.visualText.let { it.toString() to it.selection } } + .collect { + delegate.refreshValue() + } + } + } + + private fun observeFocus() { + focusObserverJob?.cancel() + focusObserverJob = coroutineScope.launch { + var focusCount = 0 + interactionSource.interactions.collect { interaction -> + when (interaction) { + is FocusInteraction.Focus -> focusCount++ + is FocusInteraction.Unfocus -> focusCount-- + else -> return@collect + } + delegate.isFocused = focusCount > 0 + } + } + } +} + +@OptIn(InternalComposeUiApi::class) +private class BasicTextFieldInputDelegate( + transformedState: TransformedTextFieldState, + override var imeOptions: ImeOptions +) : TextInputContainer.Delegate { + + var transformedState: TransformedTextFieldState = transformedState + set(value) { + field = value + refreshValue() + } + + override val editorToken: Any + get() = transformedState + + override var isFocused: Boolean = false + + private val visualText + get() = transformedState.visualText + + /** + * The last known state of the text field. + */ + override var text: String = visualText.toString() + private set + + override var selectionTextRange: TextRange = visualText.selection + private set + + override var markedTextRange: TextRange? = visualText.composition + private set + + fun refreshValue() { + val visualText = visualText + text = visualText.toString() + selectionTextRange = visualText.selection + markedTextRange = visualText.composition + } + + private inline fun edit(block: () -> Unit) { + block() + refreshValue() + } + + override fun insertText(text: String) = edit { + transformedState.replaceSelectedText(text) + } + + override fun replaceRange(range: TextRange, text: String) = edit { + transformedState.replaceText(text, range) + } + + override fun deleteBackward() = edit { + val selection = visualText.selection + if (!selection.collapsed) { + transformedState.deleteSelectedText() + } else if (selection.min > 0) { + transformedState.replaceText("", TextRange(selection.min - 1, selection.max)) + } + } + + override fun setSelectedText(range: TextRange?) = edit { + transformedState.selectCharsIn(range ?: TextRange(visualText.length)) + } + + override fun setMarkedText(markedText: String?, selectedRange: TextRange) { + if (markedText == null) { + unmarkText() + return + } + edit { + transformedState.editUntransformedTextAsUser { + val marked = composition ?: selection + replace(marked.min, marked.max, markedText) + setComposition(marked.min, marked.min + markedText.length) + val cursor = marked.min + selectedRange.min + setSelectionCoerced(cursor, cursor + selectedRange.length) + } + } + } + + override fun unmarkText() = edit { + transformedState.editUntransformedTextAsUser { commitComposition() } + } +} 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 d91faf22c62dc..b22ce7b05f587 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 @@ -14,12 +14,13 @@ * limitations under the License. */ +@file:OptIn(InternalComposeUiApi::class) + package androidx.compose.foundation.text import androidx.compose.foundation.ComposeFoundationFlags import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.text.contextmenu.data.TextContextMenuComponent import androidx.compose.foundation.text.contextmenu.data.TextContextMenuData import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItemWithComposableLeadingIcon import androidx.compose.foundation.text.contextmenu.data.TextContextMenuKeys @@ -33,141 +34,109 @@ import androidx.compose.foundation.text.contextmenu.provider.TextContextMenuProv import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.internal.selection.TextFieldSelectionState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.text.selection.SelectionContainerInputElement import androidx.compose.foundation.text.selection.SelectionManager import androidx.compose.foundation.text.selection.TextFieldSelectionManager import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.NativeTextInputContextMenuCustomAction -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.NativeTextInputContext +import androidx.compose.ui.platform.TextInputContainer import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.uikit.LocalNativeTextInputContext -import androidx.compose.ui.uikit.utils.CMPEditMenuView -import androidx.compose.ui.uikit.utils.CMPEditMenuCustomAction -import androidx.compose.ui.unit.Density import kotlin.coroutines.resume import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import org.jetbrains.skiko.OS -import org.jetbrains.skiko.OSVersion -import org.jetbrains.skiko.available -import platform.UIKit.UIView /** * Context menu area for [BasicTextField] (with [TextFieldValue] argument). */ -@OptIn(InternalComposeUiApi::class) @Composable internal actual fun ContextMenuArea( manager: TextFieldSelectionManager, content: @Composable () -> Unit ) { - val nativeTextInputContext = LocalNativeTextInputContext.current + val holder = remember(manager) { { manager.state?.holder } } if (ComposeFoundationFlags.isNewContextMenuEnabled) { - val selectionProvider = remember(manager) { - { manager.value.selection } - } - val onSelectionChanged: (TextContextMenuData) -> Unit = remember(manager, nativeTextInputContext) { - { contextMenuData -> - notifyAboutContextMenuItems( - nativeTextInputContext, - contextMenuData - ) - } - } - val nativeContextMenuUpdaterModifier = NativeTextInputContextMenuUpdaterElement( - context = nativeTextInputContext, - selectionProvider = selectionProvider, - onSelectionChanged = onSelectionChanged - ) - // The first time the menu is called up, the menu item provider contains a non-final set of // menu items, which causes the context menu callout to blink. // Adding a small delay resolves this issue. ProvideNewContextMenuDefaultProviders( - isNativeTextInputProvider = { nativeTextInputContext.usingNativeTextInput() }, + holder = holder, + selection = remember(manager) { { manager.value.selection } }, menuDelay = 100.milliseconds, - modifier = manager.contextMenuAreaModifier then nativeContextMenuUpdaterModifier, + modifier = manager.contextMenuAreaModifier, content = content ) } else { - content() - startNotifyingAboutContextMenuItems(manager, nativeTextInputContext) + LaunchedEffect(manager) { manager.updateClipboardEntry() } + val scope = rememberCoroutineScope() + LegacyNativeEditMenuArea( + holder = holder, + items = remember(manager, scope) { { manager.editMenuItems(scope) } }, + content = content + ) } } /** * Context menu area for [BasicTextField] (with [TextFieldState] argument). */ -@OptIn(InternalComposeUiApi::class) @Composable internal actual fun ContextMenuArea( selectionState: TextFieldSelectionState, enabled: Boolean, content: @Composable () -> Unit ) { - val nativeTextInputContext = LocalNativeTextInputContext.current + val holder = remember(selectionState) { { selectionState.textFieldState.holder } } if (ComposeFoundationFlags.isNewContextMenuEnabled) { - val selectionProvider = remember(selectionState) { - { selectionState.textFieldState.visualText.selection } - } - val onSelectionChanged: (TextContextMenuData) -> Unit = remember(selectionState, nativeTextInputContext) { - { contextMenuData -> - notifyAboutContextMenuItems( - nativeTextInputContext, - contextMenuData - ) - } - } - val nativeContextMenuUpdaterModifier = NativeTextInputContextMenuUpdaterElement( - context = nativeTextInputContext, - selectionProvider = selectionProvider, - onSelectionChanged = onSelectionChanged - ) - - val modifier = if (enabled) { - Modifier.showTextContextMenuOnSecondaryClick( - onPreShowContextMenu = { selectionState.updateClipboardEntry() } - ) - } else { - Modifier - } then nativeContextMenuUpdaterModifier ProvideNewContextMenuDefaultProviders( - isNativeTextInputProvider = { nativeTextInputContext.usingNativeTextInput() }, - modifier = modifier, + holder = holder, + selection = remember(selectionState) { + { selectionState.textFieldState.visualText.selection } + }, + modifier = if (enabled) { + Modifier.showTextContextMenuOnSecondaryClick( + onPreShowContextMenu = { selectionState.updateClipboardEntry() } + ) + } else { + Modifier + }, content = content ) } else { - content() - startNotifyingAboutContextMenuItems(selectionState, nativeTextInputContext) + LaunchedEffect(selectionState) { selectionState.updateClipboardEntry() } + // this should be the same scope as at the root of BasicTextField + val scope = rememberCoroutineScope() + LegacyNativeEditMenuArea( + holder = holder, + items = remember(selectionState, scope) { { selectionState.editMenuItems(scope) } }, + content = content + ) } } @@ -184,8 +153,10 @@ internal actual fun ContextMenuArea( // https://youtrack.jetbrains.com/issue/CMP-9733/Adopt-NITI-approach-to-the-Selection-Container if (ComposeFoundationFlags.isNewContextMenuEnabled) { ProvideNewContextMenuDefaultProviders( + holder = remember(manager) { { manager.holder } }, + selection = remember(manager) { { manager.selection?.toTextRange() } }, menuDelay = 100.milliseconds, - modifier = manager.contextMenuAreaModifier, + modifier = manager.contextMenuAreaModifier then SelectionContainerInputElement(manager), content = content ) } else { @@ -195,419 +166,267 @@ internal actual fun ContextMenuArea( @Composable private fun ProvideNewContextMenuDefaultProviders( - isNativeTextInputProvider: () -> Boolean = { false }, - menuDelay: Duration = 0.seconds, + holder: () -> TextInputContainer.Holder?, + selection: () -> TextRange?, + menuDelay: Duration = Duration.ZERO, modifier: Modifier = Modifier, content: @Composable () -> Unit ) { - val toolbarProvider = LocalTextContextMenuToolbarProvider.current - val dropdownProvider = LocalTextContextMenuDropdownProvider.current + var coordinates: LayoutCoordinates? by remember { mutableStateOf(null, neverEqualPolicy()) } - if (toolbarProvider == null || dropdownProvider == null) { - val layoutCoordinates: MutableState = remember { - mutableStateOf(null, neverEqualPolicy()) - } + val provider = remember(holder, menuDelay) { + ContextMenuToolbarProvider( + holder = holder, + coordinates = { coordinates }, + menuDelay = menuDelay + ) + } - val density = LocalDensity.current - val provider = remember { - val editMenuView = CMPEditMenuView().also { - it.userInteractionEnabled = false - } + CompositionLocalProvider( + LocalTextContextMenuToolbarProvider providesDefault provider, + LocalTextContextMenuDropdownProvider providesDefault provider, + ) { + Box( + modifier = modifier + .onGloballyPositioned { coordinates = it } + .then(NativeEditMenuElement(holder, selection)), + propagateMinConstraints = true + ) { + content() + } + } +} - // Native Text Input flag is being set during startInput(), which is being called later than creating this provider, - // so we need to forward it here to prevent showing several context menus - // And that's why we can't hide creating editMenuView under Native Text Input flag - ContextMenuToolbarProvider( - isNativeTextInputProvider = isNativeTextInputProvider, - menuDelay = menuDelay, - editMenuView = editMenuView, - density = density, - coordinates = { layoutCoordinates.value } +@Composable +private fun LegacyNativeEditMenuArea( + holder: () -> TextInputContainer.Holder?, + items: () -> ContextMenuItems, + content: @Composable () -> Unit +) { + LaunchedEffect(holder, items) { + snapshotFlow { holder() to items() }.collect { (holder, items) -> + holder?.updateNativeTextInputEditMenuState( + copy = items.copy, + cut = items.cut, + paste = items.paste, + selectAll = items.selectAll, + customActions = items.customActions ) } + } + content() +} - CompositionLocalProvider( - LocalTextContextMenuToolbarProvider providesDefault provider, - LocalTextContextMenuDropdownProvider providesDefault provider, - content = { - Box( - modifier = modifier.onGloballyPositioned { layoutCoordinates.value = it } - .then(ContextMenuLayoutElement(provider.editMenuView)), - propagateMinConstraints = true - ) { - content() - } +private data class NativeEditMenuElement( + private val holder: () -> TextInputContainer.Holder?, + private val selection: () -> TextRange? +) : ModifierNodeElement() { + + override fun create() = NativeEditMenuNode(holder, selection) + + override fun update(node: NativeEditMenuNode) { + node.update(holder, selection) + } + + override fun InspectorInfo.inspectableProperties() { + name = "nativeEditMenu" + } +} + +private class NativeEditMenuNode( + private var holder: () -> TextInputContainer.Holder?, + private var selection: () -> TextRange? +) : Modifier.Node() { + + private var job: Job? = null + + override fun onAttach() { + observeMenuItems() + } + + override fun onDetach() { + job?.cancel() + job = null + } + + fun update(holder: () -> TextInputContainer.Holder?, selection: () -> TextRange?) { + if (this.holder === holder && this.selection === selection) return + this.holder = holder + this.selection = selection + if (isAttached) { + observeMenuItems() + } + } + + private fun observeMenuItems() { + job?.cancel() + job = coroutineScope.launch { + snapshotFlow { + val holder = holder() ?: return@snapshotFlow null + if (!holder.usingNativeTextInput()) return@snapshotFlow null + holder to selection() } - ) - } else { - Box(modifier = modifier, propagateMinConstraints = true) { - content() + .filterNotNull() + .collect { (holder, _) -> + val items = + collectTextContextMenuData().toContextMenuItems(NoOpTextContextMenuSession) + holder.updateNativeTextInputEditMenuState( + copy = items.copy, + cut = items.cut, + paste = items.paste, + selectAll = items.selectAll, + customActions = items.customActions + ) + } } } } -@OptIn(InternalComposeUiApi::class) -private class ContextMenuItemsState( - val copy: (() -> Unit)?, - val paste: (() -> Unit)?, - val cut: (() -> Unit)?, - val selectAll: (() -> Unit)?, - val customActions: List, - val rect: Rect? = null -) - private class ContextMenuToolbarProvider( - private val isNativeTextInputProvider: () -> Boolean, - private val menuDelay: Duration, - val editMenuView: CMPEditMenuView, - private val density: Density, - private val coordinates: () -> LayoutCoordinates? -): TextContextMenuProvider { - -@OptIn(FlowPreview::class, InternalComposeUiApi::class) + private val holder: () -> TextInputContainer.Holder?, + private val coordinates: () -> LayoutCoordinates?, + private val menuDelay: Duration +) : TextContextMenuProvider { + override suspend fun showTextContextMenu(dataProvider: TextContextMenuDataProvider) { var session: TextContextMenuSession? = null coroutineScope { val job = launch { delay(menuDelay) snapshotFlow { - if (isNativeTextInputProvider()) return@snapshotFlow null - val layoutCoordinates = coordinates() ?: return@snapshotFlow null + if (holder().isNativeTextInput) return@snapshotFlow null + val coordinates = coordinates() ?: return@snapshotFlow null - val layoutPosition = layoutCoordinates.positionInWindow() - val layoutBounds = layoutCoordinates.boundsInWindow() + val rect = dataProvider.contentBounds(coordinates) + .translate(coordinates.positionInRoot()) - val rect = dataProvider.contentBounds(layoutCoordinates) - .translate(layoutPosition - layoutBounds.topLeft) - - // Without this, we would have two conflicting context menus: - // one native (updating as intended by iOS), one compose (updating by click, which can have outdated state for the Native Text Input scenario) - // So explicit filtration is required here - buildContextMenuItemsState(rect, dataProvider.data(), session) + rect to dataProvider.data().toContextMenuItems(session) } .filterNotNull() - .collect { - getEditMenuView().showEditMenuAtRect( - targetRect = (it.rect ?: Rect.Zero).toCGRect(density), - copy = it.copy, - cut = it.cut, - paste = it.paste, - select = null, - selectAll = it.selectAll, - customActions = it.customActions.map { action -> - CMPEditMenuCustomAction(action.title, action.action) - } + .collect { (rect, items) -> + holder()?.showEditMenuAtRect( + targetRect = rect, + copy = items.copy, + cut = items.cut, + paste = items.paste, + selectAll = items.selectAll, + customActions = items.customActions ) - } + } } suspendCancellableCoroutine { continuation -> - session = TextContextMenuSessionImpl(editMenuView, continuation) + session = TextContextMenuSessionImpl(holder, continuation) continuation.invokeOnCancellation { - editMenuView.hideEditMenu() + holder()?.hideEditMenu() } } job.cancel() } } - - private fun getEditMenuView(): CMPEditMenuView { - if (available(OS.Ios to OSVersion(16))) { - return editMenuView - } else { - // HACK: On iOS < 16 it's required for UIMenuController to make target view a first - // responder. If the keyboard is shown with IntermediateTextInputUIView, this will cause - // the keyboard to hide. - // To fix the problem, we're looking for the active IntermediateTextInputUIView in - // UIVIew hierarchy and use it to show the menu. - fun findEditMenuViewRecursively(view: UIView?): CMPEditMenuView? { - if (view is CMPEditMenuView) { - return view - } - view?.subviews?.forEach { - if (it is UIView) { - val editMenuView = findEditMenuViewRecursively(it) - if (editMenuView != null && editMenuView.isFirstResponder()) { - return editMenuView - } - } - } - return null - } - return findEditMenuViewRecursively(editMenuView.superview) ?: editMenuView - } - } } private class TextContextMenuSessionImpl( - val editMenuView: CMPEditMenuView, - val continuation: CancellableContinuation + private val holder: () -> TextInputContainer.Holder?, + private val continuation: CancellableContinuation ) : TextContextMenuSession { override fun close() { - editMenuView.hideEditMenu() + holder()?.hideEditMenu() if (continuation.isActive) { continuation.resume(Unit) } } } -@OptIn(InternalComposeUiApi::class) -private fun buildContextMenuItemsState( - calculatedRect: Rect?, - data: TextContextMenuData, +private class ContextMenuItems( + val copy: (() -> Unit)?, + val cut: (() -> Unit)?, + val paste: (() -> Unit)?, + val selectAll: (() -> Unit)?, + val customActions: List = emptyList() +) + +private val NoOpTextContextMenuSession = object : TextContextMenuSession { + override fun close() = Unit +} + +private fun TextContextMenuData.toContextMenuItems( session: TextContextMenuSession? -): ContextMenuItemsState { +): ContextMenuItems { var copy: (() -> Unit)? = null - var paste: (() -> Unit)? = null var cut: (() -> Unit)? = null + var paste: (() -> Unit)? = null var selectAll: (() -> Unit)? = null val customActions = mutableListOf() - fun actionItem(component: TextContextMenuComponent): (() -> Unit)? { - val item = component as? TextContextMenuItemWithComposableLeadingIcon - ?: return null - if (!item.enabled) return null + components.forEach { component -> + if (component !is TextContextMenuItemWithComposableLeadingIcon) return@forEach + if (!component.enabled) return@forEach + val action: () -> Unit = { with(component) { session?.onClick() } } - return { - with(item) { - session?.onClick() - } - } - } - - data.components.forEach { component -> when (component.key) { - TextContextMenuKeys.CopyKey -> copy = actionItem(component) - TextContextMenuKeys.PasteKey -> paste = actionItem(component) - TextContextMenuKeys.SelectAllKey -> selectAll = actionItem(component) - TextContextMenuKeys.CutKey -> cut = actionItem(component) - else -> { - if (component is TextContextMenuItemWithComposableLeadingIcon && - component.enabled - ) { - val actionItem = actionItem(component) - if (actionItem != null) { - customActions.add( - NativeTextInputContextMenuCustomAction( - title = component.label, - action = actionItem - ) - ) - } - } - } + TextContextMenuKeys.CopyKey -> copy = action + TextContextMenuKeys.CutKey -> cut = action + TextContextMenuKeys.PasteKey -> paste = action + TextContextMenuKeys.SelectAllKey -> selectAll = action + else -> customActions.add( + NativeTextInputContextMenuCustomAction( + title = component.label, + action = action + ) + ) } } - return ContextMenuItemsState( + return ContextMenuItems( copy = copy, - paste = paste, cut = cut, + paste = paste, selectAll = selectAll, - customActions = customActions, - rect = calculatedRect + customActions = customActions ) } -@OptIn(InternalComposeUiApi::class) -private data class NativeTextInputContextMenuUpdaterElement( - val context: NativeTextInputContext, - val selectionProvider: () -> TextRange, - val onSelectionChanged: (TextContextMenuData) -> Unit -): ModifierNodeElement() { - override fun create(): NativeTextInputContextMenuUpdaterNode = - NativeTextInputContextMenuUpdaterNode(context, selectionProvider, onSelectionChanged) - - override fun update(node: NativeTextInputContextMenuUpdaterNode) { - val restartRequired = node.context != context || - node.selectionProvider !== selectionProvider || - node.onSelectionChanged !== onSelectionChanged - - if (restartRequired) { - node.context = context - node.selectionProvider = selectionProvider - node.onSelectionChanged = onSelectionChanged - node.restartObserving() - } - } -} - -@OptIn(InternalComposeUiApi::class) -private class NativeTextInputContextMenuUpdaterNode( - var context: NativeTextInputContext, - var selectionProvider: () -> TextRange, - var onSelectionChanged: (TextContextMenuData) -> Unit -): DelegatingNode() { - private var job: Job? = null - - override fun onAttach() { - startObserving() - } - - override fun onDetach() { - stopObserving() - } - - private fun startObserving() { - job = coroutineScope.launch { - snapshotFlow { - if (context.usingNativeTextInput()) selectionProvider() else null - } - .filterNotNull() - .collect { - onSelectionChanged(collectTextContextMenuData()) - } - } - } - - private fun stopObserving() { - job?.cancel() - job = null - } - - fun restartObserving() { - stopObserving() - startObserving() - } -} - /** - * Starts notifying the native iOS input system about the available context menu items (isNewContextMenu = true) - * in both [BasicTextField]s (with [TextFieldState] and [TextFieldValue]) - * - * @param nativeTextInputContext The context of the native text input in UIKit to interact with for updates. - * @param contextMenuData Data for building the context menu items to display. + * The context menu items of a [BasicTextField] (with [TextFieldValue] argument) for + * [ComposeFoundationFlags.isNewContextMenuEnabled] being `false`. */ -@OptIn(InternalComposeUiApi::class) -private fun notifyAboutContextMenuItems( - nativeTextInputContext: NativeTextInputContext, - contextMenuData: TextContextMenuData -) { - // Native text input shouldn't require TextContextMenuSessionImpl, - // because native text input doesn't use CMPEditMenuView - // However, empty implementation should be passed because menu items aren't being invoked without it - val nativeTextInputTextMenuSession = object : TextContextMenuSession { - override fun close() {} - } - val contextMenuItemsState = buildContextMenuItemsState(null, contextMenuData, nativeTextInputTextMenuSession) - nativeTextInputContext.updateEditMenuState(contextMenuItemsState) -} - -@OptIn(InternalComposeUiApi::class) -@Composable -private fun startObservingSelectionChanges( - context: NativeTextInputContext, - itemsStateProvider: () -> ContextMenuItemsState, -) { - LaunchedEffect(itemsStateProvider) { - snapshotFlow { itemsStateProvider() }.collect { - context.updateNativeTextInputEditMenuState( - copy = it.copy, - paste = it.paste, - cut = it.cut, - selectAll = it.selectAll, - customActions = it.customActions - ) +private fun TextFieldSelectionManager.editMenuItems(scope: CoroutineScope): ContextMenuItems { + fun action(isEnabled: Boolean, block: () -> Unit): (() -> Unit)? { + if (!isEnabled) return null + return { + block() + scope.launch { updateClipboardEntry() } } } -} -/** - * Starts notifying the native iOS input system about the available context menu items (isNewContextMenu = false) in [BasicTextField] (with [TextFieldValue] argument) - * - * @param manager The manager responsible for tracking and controlling the text field selection. - * @param nativeTextInputContext The native text input context used to update the state of the context menu - * and provide actions such as copy, paste, cut, and select all. - */ -@OptIn(InternalComposeUiApi::class) -@Composable -private fun startNotifyingAboutContextMenuItems( - manager: TextFieldSelectionManager, - nativeTextInputContext: NativeTextInputContext, -) { - LaunchedEffect(manager) { - manager.updateClipboardEntry() - } - val scope = rememberCoroutineScope() - startObservingSelectionChanges( - nativeTextInputContext, - 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() - ) - } + return ContextMenuItems( + copy = action(isCopyAllowed()) { copy(cancelSelection = false) }, + cut = action(canShowCutMenuItem()) { cut() }, + paste = action(canShowPasteMenuItem()) { paste() }, + selectAll = action(canShowSelectAllMenuItem()) { selectAll() } ) } /** - * Starts notifying the native iOS input system about the available context menu items (isNewContextMenu = false) in [BasicTextField] (with [TextFieldState] argument) - * - * @param state The current state of the text field selection, including selection bounds - * and related actions. - * @param nativeTextInputContext The NativeTextInputContext instance used to update the edit menu state - * with actions. + * The context menu items of a [BasicTextField] (with [TextFieldState] argument) for + * [ComposeFoundationFlags.isNewContextMenuEnabled] being `false`. */ -@OptIn(InternalComposeUiApi::class) -@Composable -private fun startNotifyingAboutContextMenuItems( - state: TextFieldSelectionState, - nativeTextInputContext: NativeTextInputContext, -) { - LaunchedEffect(state) { - state.updateClipboardEntry() - } - // this should be the same scope as at the root of BasicTextField - val coroutineScope = rememberCoroutineScope() - startObservingSelectionChanges( - nativeTextInputContext, - itemsStateProvider = { - fun editBlock(isEnabled: Boolean, action: suspend () -> Unit): (() -> Unit)? { - return if (isEnabled) { - { - coroutineScope.launch { - action() - state.updateClipboardEntry() - } - } - } else { - null - } +private fun TextFieldSelectionState.editMenuItems(scope: CoroutineScope): ContextMenuItems { + fun action(isEnabled: Boolean, block: suspend () -> Unit): (() -> Unit)? { + if (!isEnabled) return null + return { + scope.launch { + block() + updateClipboardEntry() } - - 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() - ) } - ) -} - + } -@OptIn(InternalComposeUiApi::class) -private fun NativeTextInputContext.updateEditMenuState(state: ContextMenuItemsState) = - updateNativeTextInputEditMenuState( - copy = state.copy, - paste = state.paste, - cut = state.cut, - selectAll = state.selectAll, - customActions = state.customActions + return ContextMenuItems( + copy = action(canShowCopyMenuItem()) { copy(cancelSelection = false) }, + cut = action(canShowCutMenuItem()) { cut() }, + paste = action(canShowPasteMenuItem()) { paste() }, + selectAll = action(canShowSelectAllMenuItem()) { selectAll() } ) +} diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenuNode.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenuNode.ios.kt deleted file mode 100644 index eb47f82da4cb2..0000000000000 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/ContextMenuNode.ios.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright 2025 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.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.boundsInRoot -import androidx.compose.ui.node.CompositionLocalConsumerModifierNode -import androidx.compose.ui.node.GlobalPositionAwareModifierNode -import androidx.compose.ui.node.ModifierNodeElement -import androidx.compose.ui.node.ObserverModifierNode -import androidx.compose.ui.node.currentValueOf -import androidx.compose.ui.node.observeReads -import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.uikit.LocalUIView -import androidx.compose.ui.uikit.utils.CMPEditMenuView -import androidx.compose.ui.unit.Density -import platform.CoreGraphics.CGRectMake -import platform.UIKit.UIView - -internal class ContextMenuLayoutElement( - private val editMenuView: CMPEditMenuView -) : ModifierNodeElement() { - - override fun create(): ContextMenuLayoutNode { - return ContextMenuLayoutNode(editMenuView = editMenuView) - } - - override fun update(node: ContextMenuLayoutNode) { - node.update(editMenuView = editMenuView) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ContextMenuLayoutElement) return false - - if (editMenuView != other.editMenuView) return false - - return true - } - - override fun hashCode(): Int { - return editMenuView.hashCode() - } - - override fun InspectorInfo.inspectableProperties() { - name = "contextMenu" - properties["editMenuView"] = editMenuView - } -} - -internal class ContextMenuLayoutNode( - var editMenuView: CMPEditMenuView -) : Modifier.Node(), - CompositionLocalConsumerModifierNode, - GlobalPositionAwareModifierNode, - ObserverModifierNode { - - /** - * Current density provided by [LocalDensity]. Used as a receiver to callback functions that - * are expected return pixel targeted offsets. - */ - private var density: Density? = null - - private var containerView: UIView? = null - - fun update(editMenuView: CMPEditMenuView) { - if (this.editMenuView != editMenuView) { - this.editMenuView.removeFromSuperview() - this.editMenuView = editMenuView - - if (isAttached) { - containerView?.addSubview(editMenuView) - } - } - } - - override fun onAttach() { - onObservedReadsChanged() - } - - override fun onDetach() { - editMenuView.removeFromSuperview() - containerView = null - density = null - } - - override fun onObservedReadsChanged() { - observeReads { - val previousContainerView = containerView - val currentContainerView = try { - currentValueOf(LocalUIView) - } catch (_: IllegalStateException) { - null - } - density = currentValueOf(LocalDensity) - - if (previousContainerView != currentContainerView) { - containerView = currentContainerView - editMenuView.removeFromSuperview() - containerView?.addSubview(editMenuView) - } - } - } - - override fun onGloballyPositioned(coordinates: LayoutCoordinates) { - val density = density ?: return - editMenuView.setFrame(coordinates.boundsInRoot().toCGRect(density)) - } -} - -internal fun Rect.toCGRect(density: Density) = with(density) { - CGRectMake( - x = topLeft.x.toDp().value.toDouble(), - y = topLeft.y.toDp().value.toDouble(), - width = width.toDp().value.toDouble(), - height = height.toDp().value.toDouble() - ) -} \ No newline at end of file diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt index ab49eaeed7908..e0305c9412e7d 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt @@ -16,28 +16,44 @@ package androidx.compose.foundation.text -import androidx.compose.foundation.text.selection.DefaultTextSelectionColors +import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.GlobalPositionAwareModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.ObserverModifierNode import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.node.invalidateDraw import androidx.compose.ui.node.observeReads import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.TextInputContainer import androidx.compose.ui.text.TextPainter +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.CommitTextCommand +import androidx.compose.ui.text.input.DeleteSurroundingTextCommand +import androidx.compose.ui.text.input.EditCommand +import androidx.compose.ui.text.input.FinishComposingTextCommand +import androidx.compose.ui.text.input.ImeOptions import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.SetComposingRegionCommand +import androidx.compose.ui.text.input.SetComposingTextCommand +import androidx.compose.ui.text.input.SetSelectionCommand import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.uikit.LocalNativeTextInputContext +import androidx.compose.ui.uikit.LocalTextInputContainer +import androidx.compose.ui.unit.Density @OptIn(InternalComposeUiApi::class) internal actual fun Modifier.textFieldCursor( @@ -47,22 +63,18 @@ internal actual fun Modifier.textFieldCursor( cursorBrush: Brush, showCursor: Boolean, ): Modifier = composed { - val nativeInputContext = LocalNativeTextInputContext.current - val usingNativeTextInput = nativeInputContext.usingNativeTextInput() - // iOS handles cursor drawing itself in native text input mode - val selectionColors = LocalTextSelectionColors.current LaunchedEffect(selectionColors) { // iOS uses one color to draw the cursor and selection handles // If it's not user set, use the system default one - nativeInputContext.updateNativeTextInputTintColor( - selectionColors.handleColor.takeIf { - it != DefaultTextSelectionColors.handleColor - } - ) + state.holder?.updateNativeTextInputTintColor(selectionColors.nativeTintColor) } - if (usingNativeTextInput) this else cursor(state, value, offsetMapping, cursorBrush, showCursor) + if (state.holder.isNativeTextInput) { + this + } else { + cursor(state, value, offsetMapping, cursorBrush, showCursor) + } } @OptIn(InternalComposeUiApi::class) @@ -119,7 +131,9 @@ private class TextFieldDrawNode( override fun onObservedReadsChanged() { observeReads { - val usingNativeTextInput = currentValueOf(LocalNativeTextInputContext).usingNativeTextInput() + val nativeTintColor = currentValueOf(LocalTextSelectionColors).nativeTintColor + state.holder?.updateNativeTextInputTintColor(nativeTintColor) + val usingNativeTextInput = state.holder.isNativeTextInput if (usingNativeTextInput != this.usingNativeTextInput) { this.usingNativeTextInput = usingNativeTextInput invalidateDraw() @@ -167,4 +181,192 @@ private class TextFieldDrawNode( } } } -} \ No newline at end of file +} + +internal actual fun Modifier.textFieldOverlay( + state: LegacyTextFieldState, + imeOptions: ImeOptions, + interactionSource: InteractionSource? +): Modifier = this then + CoreTextFieldImeOverlayElement(state, imeOptions, state.processor.toTextFieldValue(), interactionSource) + +private data class CoreTextFieldImeOverlayElement( + private val state: LegacyTextFieldState, + private val imeOptions: ImeOptions, + private val value: TextFieldValue, + private val interactionSource: InteractionSource?, +) : ModifierNodeElement() { + + override fun create() = CoreTextFieldImeOverlayNode(state, imeOptions, interactionSource) + + override fun update(node: CoreTextFieldImeOverlayNode) { + node.update(state, imeOptions, interactionSource) + } + + override fun InspectorInfo.inspectableProperties() { + name = "coreTextFieldImeOverlay" + properties["state"] = state + properties["imeOptions"] = imeOptions + properties["value"] = value + properties["interactionSource"] = interactionSource + } +} + +@OptIn(InternalComposeUiApi::class) +private class CoreTextFieldImeOverlayNode( + private var state: LegacyTextFieldState, + imeOptions: ImeOptions, + interactionSource: InteractionSource?, +) : Modifier.Node(), + CompositionLocalConsumerModifierNode, + GlobalPositionAwareModifierNode, + ObserverModifierNode { + + private val delegate = CoreTextFieldInputDelegate(state, interactionSource, imeOptions) + + private var container: TextInputContainer? = null + + private var holder: TextInputContainer.Holder? = null + set(value) { + field = value + state.holder = value + } + + private var bounds: Rect? = null + + private var density: Density? = null + + override fun onAttach() { + onObservedReadsChanged() + } + + override fun onDetach() { + removeTextInput() + container = null + bounds = null + density = null + } + + fun update(state: LegacyTextFieldState, imeOptions: ImeOptions, interactionSource: InteractionSource?) { + if (this.state !== state) { + this.state.holder = null + this.state = state + state.holder = holder + delegate.state = state + } + delegate.imeOptions = imeOptions + delegate.interactionSource = interactionSource + } + + override fun onObservedReadsChanged() { + observeReads { + val container = currentValueOf(LocalTextInputContainer) + val density = currentValueOf(LocalDensity) + + if (container != this.container) { + removeTextInput() + this.container = container + this.density = density + createTextInput() + } else if (density != this.density) { + this.density = density + updateRect() + } + } + } + + override fun onGloballyPositioned(coordinates: LayoutCoordinates) { + bounds = coordinates.boundsInRoot() + updateRect() + } + + private fun createTextInput() { + removeTextInput() + holder = container?.createTextInput(delegate) + updateRect() + } + + private fun removeTextInput() { + holder?.remove() + holder = null + } + + private fun updateRect() { + val holder = holder ?: return + val bounds = bounds ?: return + holder.setRect(bounds) + } +} + +@OptIn(InternalComposeUiApi::class) +private class CoreTextFieldInputDelegate( + var state: LegacyTextFieldState, + var interactionSource: InteractionSource?, + override var imeOptions: ImeOptions +) : TextInputContainer.Delegate { + override val editorToken: Any + get() = state + + private val value: TextFieldValue + get() = state.processor.toTextFieldValue() + + override val text: String + get() = value.text + + override val isFocused: Boolean + get() = state.hasFocus + + override val selectionTextRange: TextRange + get() = value.selection + + override val markedTextRange: TextRange? + get() = value.composition + + override fun insertText(text: String) { + sendEditCommands(CommitTextCommand(text, 1)) + } + + override fun replaceRange(range: TextRange, text: String) { + sendEditCommands( + SetComposingRegionCommand(range.min, range.max), + SetComposingTextCommand(text, 1), + FinishComposingTextCommand(), + ) + } + + override fun deleteBackward() { + sendEditCommands( + if (value.selection.collapsed) { + DeleteSurroundingTextCommand(lengthBeforeCursor = 1, lengthAfterCursor = 0) + } else { + CommitTextCommand("", 0) + } + ) + } + + override fun setSelectedText(range: TextRange?) { + val selection = range ?: TextRange(text.length) + sendEditCommands(SetSelectionCommand(selection.min, selection.max)) + } + + override fun setMarkedText(markedText: String?, selectedRange: TextRange) { + if (markedText == null) { + unmarkText() + } else { + sendEditCommands(SetComposingTextCommand(markedText, 1)) + } + } + + override fun unmarkText() { + sendEditCommands(FinishComposingTextCommand()) + } + + private fun sendEditCommands(vararg commands: EditCommand) { + TextFieldDelegate.onEditCommand( + ops = commands.toList(), + editProcessor = state.processor, + onValueChange = state.onValueChange, + session = state.inputSession, + ) + } +} diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextInputHolder.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextInputHolder.ios.kt new file mode 100644 index 0000000000000..c4b1ace4a2934 --- /dev/null +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextInputHolder.ios.kt @@ -0,0 +1,80 @@ +/* + * 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.foundation.text.input.internal.TransformedTextFieldState +import androidx.compose.foundation.text.selection.DefaultTextSelectionColors +import androidx.compose.foundation.text.selection.SelectionManager +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.TextInputContainer +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.IntVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.nativeHeap +import kotlinx.cinterop.ptr +import platform.objc.OBJC_ASSOCIATION_RETAIN +import platform.objc.objc_getAssociatedObject +import platform.objc.objc_setAssociatedObject + +@OptIn(InternalComposeUiApi::class) +internal var LegacyTextFieldState.holder: TextInputContainer.Holder? + get() = textInputHolder + set(value) { + textInputHolder = value + } + +@OptIn(InternalComposeUiApi::class) +internal var TransformedTextFieldState.holder: TextInputContainer.Holder? + get() = textInputHolder + set(value) { + textInputHolder = value + } + +@OptIn(InternalComposeUiApi::class) +internal var SelectionManager.holder: TextInputContainer.Holder? + get() = textInputHolder + set(value) { + textInputHolder = value + } + +@OptIn(ExperimentalForeignApi::class) +private val TextInputHolderAssociationKey: COpaquePointer = nativeHeap.alloc().ptr + +/** Associated object storage, since the common text field states can't declare an iOS-only field. */ +@OptIn(ExperimentalForeignApi::class, InternalComposeUiApi::class) +private var Any.textInputHolder: TextInputContainer.Holder? + get() = + objc_getAssociatedObject(this, TextInputHolderAssociationKey) + as? TextInputContainer.Holder + set(value) { + objc_setAssociatedObject( + this, + TextInputHolderAssociationKey, + value, + OBJC_ASSOCIATION_RETAIN + ) + } + +@OptIn(InternalComposeUiApi::class) +internal val TextInputContainer.Holder?.isNativeTextInput: Boolean + get() = this?.usingNativeTextInput() == true + +internal val TextSelectionColors.nativeTintColor: Color? + get() = handleColor.takeIf { it != DefaultTextSelectionColors.handleColor } diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.ios.kt index 055cf19b2f434..f7b7c9c12d412 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.ios.kt @@ -16,8 +16,9 @@ package androidx.compose.foundation.text.input.internal +import androidx.compose.foundation.text.holder import androidx.compose.foundation.text.input.internal.selection.TextFieldSelectionState -import androidx.compose.foundation.text.selection.DefaultTextSelectionColors +import androidx.compose.foundation.text.nativeTintColor import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.graphics.Brush @@ -25,7 +26,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange -import androidx.compose.ui.uikit.LocalNativeTextInputContext +import androidx.compose.ui.uikit.LocalTextInputContainer @OptIn(InternalComposeUiApi::class) internal actual fun TextFieldCoreModifierNode.drawSelectionHighlight( @@ -33,7 +34,8 @@ internal actual fun TextFieldCoreModifierNode.drawSelectionHighlight( selection: TextRange, textLayoutResult: TextLayoutResult, ) { - val usingNativeTextInput = currentValueOf(LocalNativeTextInputContext).usingNativeTextInput() + val usingNativeTextInput = + currentValueOf(LocalTextInputContainer).activeSessionUsesNativeTextInput() // iOS handles selection drawing itself in native text input mode if (!usingNativeTextInput) { drawDefaultSelectionHighlight(scope, selection, textLayoutResult) @@ -48,16 +50,13 @@ internal actual fun TextFieldCoreModifierNode.drawCursor( cursorAnimation: CursorAnimationState?, textFieldSelectionState: TextFieldSelectionState, ) { - val nativeTextInputContext = currentValueOf(LocalNativeTextInputContext) + val holder = textFieldSelectionState.textFieldState.holder // iOS handles cursor drawing itself in native text input mode - if (nativeTextInputContext.usingNativeTextInput()) { + if (holder != null && holder.usingNativeTextInput()) { // iOS uses one color to draw the cursor and selection handles // If it's not user set, use the system default one - val selectionColors = currentValueOf(LocalTextSelectionColors) - nativeTextInputContext.updateNativeTextInputTintColor( - selectionColors.handleColor.takeIf { - it != DefaultTextSelectionColors.handleColor - } + holder.updateNativeTextInputTintColor( + currentValueOf(LocalTextSelectionColors).nativeTintColor ) } else { drawDefaultCursor(scope, brush, showCursor, cursorAnimation, textFieldSelectionState) diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/SelectionContainer.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/SelectionContainer.ios.kt new file mode 100644 index 0000000000000..52cea5b7aec9a --- /dev/null +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/SelectionContainer.ios.kt @@ -0,0 +1,174 @@ +/* + * 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.selection + +import androidx.compose.foundation.text.holder +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.GlobalPositionAwareModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.observeReads +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.TextInputContainer +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.uikit.LocalTextInputContainer +import androidx.compose.ui.unit.Density + +internal data class SelectionContainerInputElement( + private val manager: SelectionManager +) : ModifierNodeElement() { + + override fun create() = SelectionContainerInputNode(manager) + + override fun update(node: SelectionContainerInputNode) { + node.update(manager) + } + + override fun InspectorInfo.inspectableProperties() { + name = "selectionContainerInput" + properties["manager"] = manager + } +} + +@OptIn(InternalComposeUiApi::class) +internal class SelectionContainerInputNode( + private var manager: SelectionManager +) : Modifier.Node(), + CompositionLocalConsumerModifierNode, + GlobalPositionAwareModifierNode, + ObserverModifierNode { + + private var delegate = SelectionContainerInputDelegate(manager) + + private var container: TextInputContainer? = null + + private var bounds: Rect? = null + + private var density: Density? = null + + override fun onAttach() { + onObservedReadsChanged() + } + + override fun onDetach() { + removeTextInput() + container = null + bounds = null + density = null + } + + fun update(manager: SelectionManager) { + if (this.manager === manager) return + removeTextInput() + this.manager.holder = null + this.manager = manager + delegate = SelectionContainerInputDelegate(manager) + createTextInput() + } + + @OptIn(InternalComposeUiApi::class) + override fun onObservedReadsChanged() { + var container: TextInputContainer? = null + var density: Density? = null + observeReads { + container = currentValueOf(LocalTextInputContainer) + density = currentValueOf(LocalDensity) + } + + if (container != this.container) { + this.container = container + this.density = density + createTextInput() + } else if (density != this.density) { + this.density = density + updateRect() + } + } + + override fun onGloballyPositioned(coordinates: LayoutCoordinates) { + bounds = coordinates.boundsInRoot() + updateRect() + } + + private fun createTextInput() { + removeTextInput() + manager.holder = container?.createSelectionContainer(delegate) + updateRect() + } + + private fun removeTextInput() { + manager.holder?.remove() + manager.holder = null + } + + private fun updateRect() { + val holder = manager.holder ?: return + val bounds = bounds ?: return + holder.setRect(bounds) + } +} + +@OptIn(InternalComposeUiApi::class) +private class SelectionContainerInputDelegate( + private val manager: SelectionManager +) : TextInputContainer.Delegate { + private val contextTextAndSelection by derivedStateOf { manager.contextTextAndSelection() } + + /** A selection container never starts an input session of its own. */ + override val editorToken: Any? = null + + override val isFocused: Boolean = false + + override val imeOptions: ImeOptions = ImeOptions.Default + + override val markedTextRange: TextRange? = null + + override val text: String + get() = contextTextAndSelection?.first?.text.orEmpty() + + override val selectionTextRange: TextRange + get() = contextTextAndSelection?.second ?: TextRange.Zero + + override fun insertText(text: String) = Unit + + override fun replaceRange(range: TextRange, text: String) = Unit + + override fun deleteBackward() = Unit + + override fun setSelectedText(range: TextRange?) = Unit + + override fun setMarkedText(markedText: String?, selectedRange: TextRange) = Unit + + override fun unmarkText() = Unit +} + +private fun SelectionManager.contextTextAndSelection(): Pair? { + if (!isNonEmptySelection()) return null + if (containerLayoutCoordinates?.isAttached != true) return null + return getContextTextAndSelection() +} diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.ios.kt index 311a7afa54a94..fae3e3b43caf5 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.ios.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.graphics.skiaPaint import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.ResolvedTextDirection -import androidx.compose.ui.uikit.LocalNativeTextInputContext +import androidx.compose.ui.uikit.LocalTextInputContainer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize @@ -85,8 +85,8 @@ internal actual fun SelectionHandle( lineHeight: Float, modifier: Modifier ) { - val nativeInputProvider = LocalNativeTextInputContext.current - if (nativeInputProvider.usingNativeTextInput()) { + val nativeInputProvider = LocalTextInputContainer.current + if (nativeInputProvider.activeSessionUsesNativeTextInput()) { return // iOS draws selection handles itself. } val style = iosHandleStyle diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/BasicTextField.skiko.kt b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/BasicTextField.macos.kt similarity index 92% rename from compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/BasicTextField.skiko.kt rename to compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/BasicTextField.macos.kt index b6dc495fc87e3..dca6f8629443f 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/BasicTextField.skiko.kt +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/BasicTextField.macos.kt @@ -23,7 +23,5 @@ import androidx.compose.ui.Modifier internal actual fun Modifier.textFieldOverlay( transformedState: TransformedTextFieldState, keyboardOptions: KeyboardOptions, - interactionSource: InteractionSource, -): Modifier { - return this -} + interactionSource: InteractionSource +): Modifier = this \ No newline at end of file diff --git a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/CoreTextField.macos.kt b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/CoreTextField.macos.kt index 25340a3366a3f..77d0d78efb5c2 100644 --- a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/CoreTextField.macos.kt +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/CoreTextField.macos.kt @@ -16,8 +16,10 @@ package androidx.compose.foundation.text +import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.input.ImeOptions import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue @@ -34,3 +36,9 @@ internal actual fun Modifier.textFieldDraw( value: TextFieldValue, offsetMapping: OffsetMapping, ): Modifier = defaultTextFieldDraw(state, value, offsetMapping) + +internal actual fun Modifier.textFieldOverlay( + state: LegacyTextFieldState, + imeOptions: ImeOptions, + interactionSource: InteractionSource? +): Modifier = this \ No newline at end of file diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt index 53c0e5e3ab04b..7b1c175e2115f 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt @@ -67,7 +67,8 @@ internal actual fun createLegacyPlatformTextInputServiceAdapter(): makeRequest( imeOptions = imeOptions, onEditCommand = onEditCommand, - onImeActionPerformed = onImeActionPerformed + onImeActionPerformed = onImeActionPerformed, + editorToken = node.legacyTextFieldState, ) ) } @@ -107,7 +108,8 @@ internal actual fun createLegacyPlatformTextInputServiceAdapter(): private fun makeRequest( imeOptions: ImeOptions, onEditCommand: (List) -> Unit, - onImeActionPerformed: (ImeAction) -> Unit + onImeActionPerformed: (ImeAction) -> Unit, + editorToken: Any?, ): SkikoPlatformTextInputMethodRequest { val textEditorState = object : TextEditorState { override val selection: TextRange get() = textFieldValue.selection @@ -138,7 +140,8 @@ internal actual fun createLegacyPlatformTextInputServiceAdapter(): textFieldRectInRoot = { textFieldRectInRoot }, textClippingRectInRoot = { textClippingRectInRoot }, unclippedTextOffsetInRoot = { unclippedTextOffsetInRoot }, - editText = editBlock + editText = editBlock, + editorToken = editorToken, ) } } diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt index 05d07c22fc3ab..d177f18587fdd 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt @@ -115,7 +115,8 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe textFieldRectInRoot = ::textFieldRectInRoot, textClippingRectInRoot = ::textClippingRectInRoot, unclippedTextOffsetInRoot = ::unclippedTextOffsetInRoot, - editText = ::editText + editText = ::editText, + editorToken = state, ) ) } @@ -248,5 +249,6 @@ internal data class SkikoPlatformTextInputMethodRequest( override val textFieldRectInRoot: () -> Rect?, override val textClippingRectInRoot: () -> Rect?, override val unclippedTextOffsetInRoot: () -> Offset?, - override val editText: (block: TextEditingScope.() -> Unit) -> Unit + override val editText: (block: TextEditingScope.() -> Unit) -> Unit, + override val editorToken: Any?, ): PlatformTextInputMethodRequest diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CoreTextField.skiko.kt b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/BasicTextField.web.kt similarity index 71% rename from compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CoreTextField.skiko.kt rename to compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/BasicTextField.web.kt index d9a6ac546661a..dca6f8629443f 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CoreTextField.skiko.kt +++ b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/BasicTextField.web.kt @@ -17,15 +17,11 @@ package androidx.compose.foundation.text import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.internal.TransformedTextFieldState import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.ImeOptions -// TODO https://youtrack.jetbrains.com/issue/CMP-10340/Implement-textFieldOverlay internal actual fun Modifier.textFieldOverlay( - state: LegacyTextFieldState, - imeOptions: ImeOptions, - interactionSource: InteractionSource?, -): Modifier { - return this -} + transformedState: TransformedTextFieldState, + keyboardOptions: KeyboardOptions, + interactionSource: InteractionSource +): Modifier = this \ No newline at end of file diff --git a/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/CoreTextField.web.kt b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/CoreTextField.web.kt index 25340a3366a3f..77d0d78efb5c2 100644 --- a/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/CoreTextField.web.kt +++ b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/CoreTextField.web.kt @@ -16,8 +16,10 @@ package androidx.compose.foundation.text +import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.input.ImeOptions import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue @@ -34,3 +36,9 @@ internal actual fun Modifier.textFieldDraw( value: TextFieldValue, offsetMapping: OffsetMapping, ): Modifier = defaultTextFieldDraw(state, value, offsetMapping) + +internal actual fun Modifier.textFieldOverlay( + state: LegacyTextFieldState, + imeOptions: ImeOptions, + interactionSource: InteractionSource? +): Modifier = this \ No newline at end of file diff --git a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.ios.kt b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeatures.kt similarity index 96% rename from compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.ios.kt rename to compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeatures.kt index 26d99a895f4ef..1024709eb2aea 100644 --- a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeaturesExample.ios.kt +++ b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeatures.kt @@ -30,4 +30,5 @@ val IosSpecificFeatures = Screen.Selection( UpdatableInteropPropertiesExample, IosImeOptionsExample, NativeTextInputTextFields, -) + AutoSafeLoginPasswordExample, +) \ No newline at end of file diff --git a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSafeLoginPasswordExample.ios.kt b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSafeLoginPasswordExample.ios.kt new file mode 100644 index 0000000000000..d9f02cde770d0 --- /dev/null +++ b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSafeLoginPasswordExample.ios.kt @@ -0,0 +1,304 @@ +/* + * 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.mpp.demo + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.PlatformImeOptions +import androidx.compose.ui.uikit.LocalUIView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.UIKitInteropProperties +import androidx.compose.ui.viewinterop.UIKitView +import kotlin.time.Duration.Companion.seconds +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCAction +import kotlinx.cinterop.readValue +import kotlinx.coroutines.delay +import platform.CoreGraphics.CGRectZero +import platform.Foundation.NSSelectorFromString +import platform.UIKit.UIControlEventEditingChanged +import platform.UIKit.UIKeyboardTypeEmailAddress +import platform.UIKit.UITextAutocapitalizationType +import platform.UIKit.UITextAutocorrectionType +import platform.UIKit.UITextBorderStyle +import platform.UIKit.UITextContentTypePassword +import platform.UIKit.UITextContentTypeUsername +import platform.UIKit.UITextField +import platform.UIKit.UITextSpellCheckingType +import platform.UIKit.endEditing + +val AutoSafeLoginPasswordExample = Screen.Selection( + title = "Autosave Login & Password", + screens = listOf( + Screen.Fullscreen("UITextField safe password") { back -> UITextFieldSafePassword(back) }, + Screen.Fullscreen("Core Text Field safe password") { back -> ComposeCoreTextFieldSafePassword(back) }, + Screen.Fullscreen("Basic Text Field safe password") { back -> ComposeBasicTextFieldSafePassword(back) }, + ) +) + +@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) +@Composable +private fun UITextFieldSafePassword(back: () -> Unit) { + var login by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var isLoggingIn by remember { mutableStateOf(false) } + + if (isLoggingIn) { + LaunchedEffect(Unit) { + delay(1.seconds) + back() + } + } + + Column(Modifier.padding(16.dp).safeDrawingPadding()) { + Text("Login (UITextField)") + Spacer(Modifier.height(4.dp)) + UIKitView( + factory = { + val textField = object : UITextField(CGRectZero.readValue()) { + @ObjCAction + fun editingChanged() { + login = text ?: "" + } + } + textField.placeholder = "Email" + textField.borderStyle = UITextBorderStyle.UITextBorderStyleRoundedRect + textField.autocapitalizationType = UITextAutocapitalizationType.UITextAutocapitalizationTypeNone + textField.autocorrectionType = UITextAutocorrectionType.UITextAutocorrectionTypeNo + textField.spellCheckingType = UITextSpellCheckingType.UITextSpellCheckingTypeNo + textField.keyboardType = UIKeyboardTypeEmailAddress + textField.textContentType = UITextContentTypeUsername + textField.addTarget( + target = textField, + action = NSSelectorFromString(textField::editingChanged.name), + forControlEvents = UIControlEventEditingChanged + ) + textField + }, + modifier = Modifier.fillMaxWidth().height(40.dp), + update = { it.text = login }, + properties = UIKitInteropProperties(isNativeAccessibilityEnabled = true) + ) + + Spacer(Modifier.height(12.dp)) + Text("Password (UITextField)") + Spacer(Modifier.height(4.dp)) + UIKitView( + factory = { + val textField = object : UITextField(CGRectZero.readValue()) { + @ObjCAction + fun editingChanged() { + password = text ?: "" + } + } + textField.placeholder = "Password" + textField.borderStyle = UITextBorderStyle.UITextBorderStyleRoundedRect + textField.autocapitalizationType = UITextAutocapitalizationType.UITextAutocapitalizationTypeNone + textField.autocorrectionType = UITextAutocorrectionType.UITextAutocorrectionTypeNo + textField.spellCheckingType = UITextSpellCheckingType.UITextSpellCheckingTypeNo + textField.secureTextEntry = true + textField.textContentType = UITextContentTypePassword + textField.addTarget( + target = textField, + action = NSSelectorFromString(textField::editingChanged.name), + forControlEvents = UIControlEventEditingChanged + ) + textField + }, + modifier = Modifier.fillMaxWidth().height(40.dp), + update = { it.text = password }, + properties = UIKitInteropProperties(isNativeAccessibilityEnabled = true) + ) + + val localView = LocalUIView.current + Button({ localView.superview?.endEditing(true) }) { + Text("End editing") + } + + Spacer(Modifier.height(16.dp)) + Button( + onClick = { isLoggingIn = true }, + enabled = login.isNotEmpty() && password.isNotEmpty() && !isLoggingIn + ) { + Text(if (isLoggingIn) "Logging in..." else "Login") + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun ComposeCoreTextFieldSafePassword(back: () -> Unit) { + var login by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var isLoggingIn by remember { mutableStateOf(false) } + + if (isLoggingIn) { + LaunchedEffect(Unit) { + delay(1.seconds) + back() + } + } + + Column(Modifier.padding(16.dp).safeDrawingPadding()) { + val fieldModifier = Modifier + .fillMaxWidth() + .border(1.dp, Color.LightGray, RoundedCornerShape(4.dp)) + .padding(4.dp) + + Text("Login (Compose TextField)") + Spacer(Modifier.height(4.dp)) + TextField( + value = login, + onValueChange = { login = it }, + modifier = fieldModifier, + placeholder = { Text("Email") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + platformImeOptions = PlatformImeOptions { + textContentType(UITextContentTypeUsername) + } + ) + ) + + Spacer(Modifier.height(12.dp)) + Text("Password (Compose TextField)") + Spacer(Modifier.height(4.dp)) + TextField( + value = password, + onValueChange = { password = it }, + modifier = fieldModifier, + placeholder = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + platformImeOptions = PlatformImeOptions { + isSecureTextEntry(true) + } + ) + ) + + val manager = LocalFocusManager.current + Button({ manager.clearFocus() }) { + Text("End editing") + } + + Spacer(Modifier.height(16.dp)) + Button( + onClick = { isLoggingIn = true }, + enabled = login.isNotEmpty() && password.isNotEmpty() && !isLoggingIn + ) { + Text(if (isLoggingIn) "Logging in..." else "Login") + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun ComposeBasicTextFieldSafePassword(back: () -> Unit) { + val login = rememberTextFieldState() + val password = rememberTextFieldState() + var isLoggingIn by remember { mutableStateOf(false) } + + if (isLoggingIn) { + LaunchedEffect(Unit) { + delay(1.seconds) + back() + } + } + + Column(Modifier.padding(16.dp).safeDrawingPadding()) { + val fieldModifier = Modifier + .fillMaxWidth() + .border(1.dp, Color.LightGray, RoundedCornerShape(4.dp)) + .padding(4.dp) + + Text("Login (Compose TextField)") + Spacer(Modifier.height(4.dp)) + TextField( + state = login, + modifier = fieldModifier, + placeholder = { Text("Email") }, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + platformImeOptions = PlatformImeOptions { + textContentType(UITextContentTypeUsername) + } + ) + ) + + Spacer(Modifier.height(12.dp)) + Text("Password (Compose TextField)") + Spacer(Modifier.height(4.dp)) + TextField( + state = password, + modifier = fieldModifier, + placeholder = { Text("Password") }, + lineLimits = TextFieldLineLimits.SingleLine, + outputTransformation = remember { + OutputTransformation { replace(0, length, "*".repeat(length)) } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + platformImeOptions = PlatformImeOptions { + isSecureTextEntry(true) + } + ) + ) + + val manager = LocalFocusManager.current + Button({ manager.clearFocus() }) { + Text("End editing") + } + + Spacer(Modifier.height(16.dp)) + Button( + onClick = { isLoggingIn = true }, + enabled = login.text.isNotEmpty() && password.text.isNotEmpty() && !isLoggingIn + ) { + Text(if (isLoggingIn) "Logging in..." else "Login") + } + } +} diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPEditMenuView.h b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPEditMenuView.h index a6d9c80190340..86065b0dfa90f 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPEditMenuView.h +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPEditMenuView.h @@ -16,6 +16,7 @@ #import #import "CMPEditMenuCustomAction.h" +#import "CMPMacros.h" @interface CMPEditMenuView : UIView @@ -43,4 +44,6 @@ - (UIView *)inputAccessoryView; +- (BOOL)isSecureTextEntry CMP_ABSTRACT_FUNCTION; + @end diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPEditMenuView.m b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPEditMenuView.m index f36f865ca1971..1b359172f9950 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPEditMenuView.m +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPEditMenuView.m @@ -86,9 +86,15 @@ @interface CMPEditMenuView() @property (readwrite) UIEditMenuInteraction* editInteraction API_AVAILABLE(ios(16.0)); +/// Donor text field backing the `UITextField` masquerade below, or `nil` when secure text entry is off. +- (nullable UITextField *)cmp_proxyTextField; + @end -@implementation CMPEditMenuView +@implementation CMPEditMenuView { + UITextField *_textField; + BOOL _isDeallocating; +} id _editInteraction; @@ -152,6 +158,65 @@ - (void)updateAvailableSystemActions:(void (^)(void))copyBlock self.systemSelectAllBlock = selectAllBlock; } +- (BOOL)isSecureTextEntry { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (UITextField *)cmp_proxyTextField { + if (![self isSecureTextEntry]) { + return nil; + } + if (!_textField) { + _textField = [[UITextField alloc] init]; + } + return _textField; +} + +/// `-[UIView dealloc]` still queries the view while tearing it down (`-isKindOfClass:` from +/// `-_removeAllGestureRecognizers`, for example). The Kotlin subclass releases its state in its own +/// `-dealloc` before `super` runs, so from here on the subclass can no longer be asked anything. +- (void)dealloc { + _isDeallocating = YES; +} + +- (BOOL)isKindOfClass:(Class)aClass { + if ([super isKindOfClass:aClass]) { + return YES; + } + if (_isDeallocating) { + return NO; + } + UITextField *proxyTextField = [self cmp_proxyTextField]; + return proxyTextField != nil && [proxyTextField isKindOfClass:aClass]; +} + +- (NSMethodSignature*)methodSignatureForSelector:(SEL)aSelector { + NSMethodSignature* signature = [super methodSignatureForSelector:aSelector]; + if (!signature) { + signature = [[self cmp_proxyTextField] methodSignatureForSelector:aSelector]; + } + return signature; +} + +- (void)forwardInvocation:(NSInvocation*)anInvocation { + UITextField *proxyTextField = [self cmp_proxyTextField]; + if (proxyTextField != nil) { + [anInvocation invokeWithTarget:proxyTextField]; + } else { + [super forwardInvocation:anInvocation]; + } +} + +- (nullable NSString *)text { + NSAssert([self conformsToProtocol:@protocol(UITextInput)], + @"-text requires a subclass conforming to UITextInput"); + + id textInput = (id)self; + UITextRange *range = [textInput textRangeFromPosition:textInput.beginningOfDocument + toPosition:textInput.endOfDocument]; + return range != nil ? [textInput textInRange:range] : nil; +} + - (void)didMoveToWindow { [super didMoveToWindow]; diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPTextInputView.h b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPTextInputView.h index 2e0111a1605d9..230c2d552f02d 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPTextInputView.h +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPTextInputView.h @@ -34,4 +34,6 @@ NS_ASSUME_NONNULL_END - (nullable UIView *)inputAccessoryView; +- (BOOL)isSecureTextEntry CMP_ABSTRACT_FUNCTION; + @end diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPTextInputView.m b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPTextInputView.m index 8d3e660aa901e..d05aa15a856ef 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPTextInputView.m +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitObjcUtils/CMPTextInputView.m @@ -16,7 +16,16 @@ #import "CMPTextInputView.h" -@implementation CMPTextInputView +@interface CMPTextInputView () + +- (nullable UITextField *)cmp_proxyTextField; + +@end + +@implementation CMPTextInputView { + UITextField *_textField; + BOOL _isDeallocating; +} @synthesize beginningOfDocument; @synthesize hasText; @@ -125,6 +134,61 @@ - (void)insertText:(nonnull NSString *)text { CMP_ABSTRACT_FUNCTION_CALLED } +- (BOOL)isSecureTextEntry { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (UITextField *)cmp_proxyTextField { + if (![self isSecureTextEntry]) { + return nil; + } + if (!_textField) { + _textField = [[UITextField alloc] init]; + } + return _textField; +} + +/// `-[UIView dealloc]` still queries the view while tearing it down (`-isKindOfClass:` from +/// `-_removeAllGestureRecognizers`, for example). The Kotlin subclass releases its state in its own +/// `-dealloc` before `super` runs, so from here on the subclass can no longer be asked anything. +- (void)dealloc { + _isDeallocating = YES; +} + +- (BOOL)isKindOfClass:(Class)aClass { + if ([super isKindOfClass:aClass]) { + return YES; + } + if (_isDeallocating) { + return NO; + } + UITextField *proxyTextField = [self cmp_proxyTextField]; + return proxyTextField != nil && [proxyTextField isKindOfClass:aClass]; +} + +- (NSMethodSignature*)methodSignatureForSelector:(SEL)aSelector { + NSMethodSignature* signature = [super methodSignatureForSelector:aSelector]; + if (!signature) { + signature = [[self cmp_proxyTextField] methodSignatureForSelector:aSelector]; + } + return signature; +} + +- (void)forwardInvocation:(NSInvocation*)anInvocation { + UITextField *proxyTextField = [self cmp_proxyTextField]; + if (proxyTextField != nil) { + [anInvocation invokeWithTarget:proxyTextField]; + } else { + [super forwardInvocation:anInvocation]; + } +} + +- (nullable NSString *)text { + UITextRange *range = [self textRangeFromPosition:self.beginningOfDocument + toPosition:self.endOfDocument]; + return range != nil ? [self textInRange:range] : nil; +} + - (void)activateTextInputInteractionIfNeeded { if (@available(iOS 17, *)) { for (id interaction in self.interactions) { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/NativeTextInputContext.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/NativeTextInputContext.ios.kt index 79b56d8fcfed6..184d5fd502a1a 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/NativeTextInputContext.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/NativeTextInputContext.ios.kt @@ -17,30 +17,6 @@ package androidx.compose.ui.platform import androidx.compose.ui.InternalComposeUiApi -import androidx.compose.ui.graphics.Color - -/** - * Interface for providing the required information for the iOS to use native text editing - * experience. - * - * The main difference between this approach and the default compose one is that iOS handles - * the caret, selection handles, selection area, related gestures and context menu appearance - * and behavior itself. - */ -@InternalComposeUiApi -interface NativeTextInputContext { - fun usingNativeTextInput(): Boolean - - fun updateNativeTextInputEditMenuState( - copy: (() -> Unit)?, - paste: (() -> Unit)?, - cut: (() -> Unit)?, - selectAll: (() -> Unit)?, - customActions: List? - ) - - fun updateNativeTextInputTintColor(color: Color?) -} @InternalComposeUiApi class NativeTextInputContextMenuCustomAction( diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextEditingDelegateAdapter.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextEditingDelegateAdapter.ios.kt new file mode 100644 index 0000000000000..90b7fe05584b8 --- /dev/null +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextEditingDelegateAdapter.ios.kt @@ -0,0 +1,92 @@ +/* + * 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.platform + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect + +/** + * Adapts the [TextInputContainer.Delegate] of a text field to the interface the + * [NativeTextEditingDelegate] conforming views talk to. + */ +internal class InactiveTextInputAdapter( + private val delegate: TextInputContainer.Delegate, +) : NativeTextEditingDelegate { + override val isInteractive: Boolean = false + + private val text: String get() = delegate.text + + override fun onResignFocus() = Unit + + override fun beginFloatingCursor(offset: DpOffset) = Unit + + override fun updateFloatingCursor(offset: DpOffset) = Unit + + override fun endFloatingCursor() = Unit + + override fun hasText(): Boolean = text.isNotEmpty() + + override fun insertText(text: String) = delegate.insertText(text) + + override fun deleteBackward() = delegate.deleteBackward() + + override fun endOfDocument(): Int = text.length + + override fun getSelectedTextRange(): TextRange = delegate.selectionTextRange + + override fun setSelectedTextRange(range: TextRange?) = delegate.setSelectedText(range) + + override fun selectAll() = delegate.setSelectedText(TextRange(0, text.length)) + + override fun textInRange(range: TextRange): String? = + text.takeIf { range.isValidIn(it.length) }?.substring(range.start, range.end) + + override fun replaceRange(range: TextRange, text: String) = delegate.replaceRange(range, text) + + override fun setMarkedText(markedText: String?, selectedRange: TextRange) = + delegate.setMarkedText(markedText, selectedRange) + + override fun markedTextRange(): TextRange? = delegate.markedTextRange + + override fun unmarkText() = delegate.unmarkText() + + override fun positionFromPosition(position: Int, offset: Int): Int? = + text.movePositionByGraphemes(position, offset) + + override fun verticalPositionFromPosition(position: Int, verticalOffset: Int): Int? = null + + override fun caretDpRectForPosition(position: Int): DpRect? = null + + override fun selectionDpRectsForRange(range: TextRange): List = + emptyList() + + override fun firstSelectionRectForRange(range: TextRange): DpRect? = null + + override fun closestPositionToPoint(point: DpOffset): Int? = null + + override fun closestPositionToPoint(point: DpOffset, withinRange: TextRange): Int? = null + + override fun characterRangeAtPoint(point: DpOffset): TextRange? = null + + override val inputTraits: SkikoUITextInputTraits get() = getUITextInputTraits(delegate.imeOptions) + + override fun positionWithinRange( + range: TextRange, + farthestInDirection: TextLayoutDirection + ): Int? = null +} diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputContainer.ios.kt new file mode 100644 index 0000000000000..f16c0f49897f8 --- /dev/null +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputContainer.ios.kt @@ -0,0 +1,143 @@ +/* + * 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.platform + +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.unit.Density + +/** + * A per-scene factory of platform text inputs, each backed by a `UIView` that becomes the first + * responder so that iOS can drive the keyboard, IME, the edit menu and native text selection over + * text that lives in Compose state. + */ +@InternalComposeUiApi +interface TextInputContainer { + /** + * A handle to a single text input, owned by the composable that requested it. + */ + interface Holder { + fun setRect(rect: Rect) + fun remove() + + fun showEditMenuAtRect( + targetRect: Rect, + copy: (() -> Unit)?, + cut: (() -> Unit)?, + paste: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List? + ) + fun hideEditMenu() + + fun updateNativeTextInputEditMenuState( + copy: (() -> Unit)?, + cut: (() -> Unit)?, + paste: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List? + ) + + fun updateNativeTextInputTintColor(color: Color?) + + fun usingNativeTextInput(): Boolean + } + + /** + * Provides a temporary connection between non-editable text field and iOS text input. + * Used to support the auto-safe/autofill password feature. + */ + interface Delegate { + val text: String + val isFocused: Boolean + val selectionTextRange: TextRange + val markedTextRange: TextRange? + val imeOptions: ImeOptions + val editorToken: Any? + + fun insertText(text: String) + fun replaceRange(range: TextRange, text: String) + fun deleteBackward() + fun setSelectedText(range: TextRange?) + fun setMarkedText(markedText: String?, selectedRange: TextRange) + fun unmarkText() + } + + /** + * Attaches an editable text input for [delegate] to the scene. + */ + fun createTextInput(delegate: Delegate): Holder + + /** + * Attaches a non-editable text selection containfor [delegate] to the scene. + */ + fun createSelectionContainer(delegate: Delegate): Holder + + /** + * HACK: In some cases it's impossible to detect if the native text input is attached to + * particular [Holder]. In order to fix that, return extra flag that indicates if the current + * active text input is the native text input. + */ + fun activeSessionUsesNativeTextInput(): Boolean +} + +/** + * A [TextInputContainer] that provides no platform text input, used when the scene is not hosted by + * UIKit views, as in tests. Text fields then draw and edit the text themselves. + */ +internal object EmptyTextInputContainer : TextInputContainer { + + private object EmptyHolder : TextInputContainer.Holder { + override fun setRect(rect: Rect) = Unit + + override fun remove() = Unit + + override fun showEditMenuAtRect( + targetRect: Rect, + copy: (() -> Unit)?, + cut: (() -> Unit)?, + paste: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List? + ) = Unit + + override fun hideEditMenu() = Unit + + override fun updateNativeTextInputEditMenuState( + copy: (() -> Unit)?, + cut: (() -> Unit)?, + paste: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List? + ) = Unit + + override fun updateNativeTextInputTintColor(color: Color?) = Unit + + override fun usingNativeTextInput(): Boolean = false + } + + override fun createTextInput(delegate: TextInputContainer.Delegate): TextInputContainer.Holder = + EmptyHolder + + override fun createSelectionContainer(delegate: TextInputContainer.Delegate): TextInputContainer.Holder = + EmptyHolder + + override fun activeSessionUsesNativeTextInput(): Boolean = false +} 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 1ae24776089f6..c4e88fa24cbf8 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 @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.toCGRect +import kotlin.math.absoluteValue import kotlinx.cinterop.CValue import org.jetbrains.skia.BreakIterator import platform.CoreGraphics.CGRect @@ -219,11 +220,13 @@ internal interface NativeTextEditingDelegate : TextEditingDelegate { fun positionWithinRange(range: TextRange, farthestInDirection: TextLayoutDirection): Int? } -internal object EmptyTextEditingDelegate : NativeTextEditingDelegate { +internal class DetachedTextEditingDelegate( + private val text: String = "", + private val selection: TextRange? = null, + override val inputTraits: SkikoUITextInputTraits = EmptyInputTraits, +) : NativeTextEditingDelegate { override val isInteractive: Boolean = false - override val inputTraits: SkikoUITextInputTraits = EmptyInputTraits - override fun onResignFocus() = Unit override fun beginFloatingCursor(offset: DpOffset) = Unit @@ -232,21 +235,22 @@ internal object EmptyTextEditingDelegate : NativeTextEditingDelegate { override fun endFloatingCursor() = Unit - override fun hasText(): Boolean = false + override fun hasText(): Boolean = text.isNotEmpty() override fun insertText(text: String) = Unit override fun deleteBackward() = Unit - override fun endOfDocument(): Int = 0 + override fun endOfDocument(): Int = text.length - override fun getSelectedTextRange(): TextRange? = null + override fun getSelectedTextRange(): TextRange? = selection override fun setSelectedTextRange(range: TextRange?) = Unit override fun selectAll() = Unit - override fun textInRange(range: TextRange): String? = null + override fun textInRange(range: TextRange): String? = + text.takeIf { range.isValidIn(it.length) }?.substring(range.start, range.end) override fun replaceRange(range: TextRange, text: String) = Unit @@ -256,7 +260,8 @@ internal object EmptyTextEditingDelegate : NativeTextEditingDelegate { override fun unmarkText() = Unit - override fun positionFromPosition(position: Int, offset: Int): Int? = null + override fun positionFromPosition(position: Int, offset: Int): Int? = + text.movePositionByGraphemes(position, offset) override fun verticalPositionFromPosition(position: Int, verticalOffset: Int): Int? = null @@ -279,6 +284,44 @@ internal object EmptyTextEditingDelegate : NativeTextEditingDelegate { ): Int? = null } +internal fun NativeTextEditingDelegate.detachedCopy() = DetachedTextEditingDelegate( + text = textInRange(TextRange(0, endOfDocument())).orEmpty(), + selection = getSelectedTextRange(), + inputTraits = inputTraits, +) + +internal fun TextRange.isValidIn(length: Int): Boolean = + start >= 0 && start <= end && end <= length + +internal fun String.movePositionByGraphemes(position: Int, offset: Int): Int? { + val newPosition = position + offset + if (newPosition == length || newPosition == 0) { + return newPosition + } + if (newPosition < 0 || newPosition > length) { + return null + } + var resultPosition = position + val iterator = BreakIterator.makeCharacterInstance() + iterator.setText(this) + + repeat(offset.absoluteValue) { + val iteratorResult = if (offset > 0) { + iterator.following(resultPosition) + } else { + iterator.preceding(resultPosition) + } + + if (iteratorResult == BreakIterator.DONE) { + return resultPosition + } else { + resultPosition = iteratorResult + } + } + + return resultPosition +} + internal fun TextEditingDelegate.selectTextNearCursor() { val selection = getSelectedTextRange() ?: return val text = textInRange(TextRange(0, endOfDocument())) ?: return diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt index 08a18eca4ea4a..f167f5c87a1ca 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt @@ -38,15 +38,25 @@ import androidx.compose.ui.text.input.TextEditingScope import androidx.compose.ui.text.input.TextEditorState import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextInputConnection +import androidx.compose.ui.text.input.TextInputConnection.Companion.CLEAR_FOCUS_DELAY import androidx.compose.ui.text.input.stateSnapshot import androidx.compose.ui.text.input.usingNativeTextInput +import androidx.compose.ui.uikit.density +import androidx.compose.ui.unit.toCGRect +import androidx.compose.ui.unit.toDpRect import androidx.compose.ui.window.FocusedViewsList import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import platform.CoreGraphics.CGRectMake import platform.UIKit.UIView +import platform.darwin.DISPATCH_TIME_NOW +import platform.darwin.NSEC_PER_MSEC +import platform.darwin.dispatch_after +import platform.darwin.dispatch_get_main_queue +import platform.darwin.dispatch_time @OptIn(ExperimentalComposeUiApi::class) internal class TextInputService( @@ -71,11 +81,11 @@ internal class TextInputService( private var currentInputConnection: TextInputConnection? by mutableStateOf(null) - private var updateEditMenuState = {} - val hasInvalidations: Boolean get() = currentInputConnection?.hasInvalidations ?: false + private val holders = mutableSetOf() + suspend fun startInputMethod(request: PlatformTextInputMethodRequest): Nothing { coroutineScope { launch { @@ -105,33 +115,19 @@ internal class TextInputService( } private fun startInput(request: PlatformTextInputMethodRequest) { - val usingNativeTextInput = request.imeOptions.platformImeOptions?.usingNativeTextInput ?: false - currentInputConnection?.stop() listener.onInputWillStart() - currentInputConnection = if (usingNativeTextInput) { - NativeTextInputConnection( - updateView = updateView, - view = view, - coroutineScope = coroutineScope, - focusedViewsList = focusedViewsList, - focusManager = focusManager, - ) - } else { - ComposeTextInputConnection( - updateView = updateView, - view = view, - coroutineScope = coroutineScope, - viewConfiguration = viewConfiguration, - focusedViewsList = focusedViewsList, - focusManager = focusManager - ) - } + + currentInputConnection = holderFor(request)?.connection currentInputConnection?.start(request) - updateEditMenuState() listener.onInputDidStart() } + private fun holderFor(request: PlatformTextInputMethodRequest): TextInputHolder? { + val editorToken = request.editorToken + return holders.firstOrNull { it.delegate.editorToken === editorToken } + } + private fun stopInput() { currentInputConnection?.stop() currentInputConnection = null @@ -161,49 +157,14 @@ internal class TextInputService( onCutRequested: (() -> Unit)?, onSelectAllRequested: (() -> Unit)? ) { - if (currentInputConnection == null) { - // Entry point for showing the context menu in SelectionContainer scenarios, where - // there is no active text input session. iOS requires a UIView that can become first - // responder in order to host the context menu, so we create a dedicated connection - // backed by a hidden view for this purpose. - // Note: start() is intentionally not called here — it establishes a text editing - // session (requiring a PlatformTextInputMethodRequest) which is not applicable for - // SelectionContainer. - currentInputConnection = SelectionContainerConnection( - view = view, - coroutineScope = coroutineScope, - viewConfiguration = viewConfiguration, - focusManager = focusManager - ) - currentInputConnection?.start( - object : PlatformTextInputMethodRequest { - override val value: () -> TextFieldValue get() = { TextFieldValue() } - override val state: TextEditorState = object : TextEditorState { - override val selection: TextRange get() = TextRange(0, 0) - override val composition: TextRange? get() = null - override val length: Int get() = 0 - override fun get(index: Int): Char = ' ' - override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = "" - override val text: String get() = "" - } - override val imeOptions: ImeOptions get() = ImeOptions.Default - override val onEditCommand: (List) -> Unit get() = { _ -> } - override val onImeAction: ((ImeAction) -> Unit)? get() = null - override val textLayoutResult: () -> TextLayoutResult? get() = { null } - override val focusedRectInRoot: () -> Rect? get() = { null } - override val textFieldRectInRoot: () -> Rect? get() = { null } - override val textClippingRectInRoot: () -> Rect? get() = { null } - override val unclippedTextOffsetInRoot: () -> Offset? get() = { null } - override val editText: (block: TextEditingScope.() -> Unit) -> Unit get() = { _ -> } - } - ) - } - (currentInputConnection as? ComposeTextInputConnection)?.showToolbarMenu( + val connection = activeMenuConnection() ?: return + connection.showToolbarMenu( rect = rect, onCopyRequested = onCopyRequested, onPasteRequested = onPasteRequested, onCutRequested = onCutRequested, - onSelectAllRequested = onSelectAllRequested + onSelectAllRequested = onSelectAllRequested, + customActions = null, ) } @@ -211,57 +172,211 @@ internal class TextInputService( (currentInputConnection as? ComposeTextInputConnection)?.hideToolbar() if (currentInputConnection is SelectionContainerConnection) { - // stop() removes the view from the hierarchy and resigns first responder, - // without requiring a prior start() call. - currentInputConnection?.stop() + currentInputConnection?.rootView?.removeFromSuperview() + currentInputConnection?.dispose() currentInputConnection = null } } + + private fun activeMenuConnection(): ComposeTextInputConnection? { + currentInputConnection?.let { return it as? ComposeTextInputConnection } + holders.firstOrNull { it.delegate.isFocused } + ?.let { it.connection as? ComposeTextInputConnection } + ?.let { + currentInputConnection?.stop() + currentInputConnection = it + return it + } + return startSelectionContainerConnection() + } + + private fun startSelectionContainerConnection(): ComposeTextInputConnection { + val connection = SelectionContainerConnection( + coroutineScope = coroutineScope, + viewConfiguration = viewConfiguration, + focusManager = focusManager + ).also { + it.rootView.setFrame(view.bounds) + view.addSubview(it.rootView) + } + currentInputConnection = connection + connection.start( + object : PlatformTextInputMethodRequest { + override val value: () -> TextFieldValue get() = { TextFieldValue() } + override val state: TextEditorState = object : TextEditorState { + override val selection: TextRange get() = TextRange(0, 0) + override val composition: TextRange? get() = null + override val length: Int get() = 0 + override fun get(index: Int): Char = ' ' + override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = "" + override val text: String get() = "" + } + override val imeOptions: ImeOptions get() = ImeOptions.Default + override val onEditCommand: (List) -> Unit get() = { _ -> } + override val onImeAction: ((ImeAction) -> Unit)? get() = null + override val textLayoutResult: () -> TextLayoutResult? get() = { null } + override val focusedRectInRoot: () -> Rect? get() = { null } + override val textFieldRectInRoot: () -> Rect? get() = { null } + override val textClippingRectInRoot: () -> Rect? get() = { null } + override val unclippedTextOffsetInRoot: () -> Offset? get() = { null } + override val editText: (block: TextEditingScope.() -> Unit) -> Unit get() = { _ -> } + } + ) + return connection + } } } - val nativeTextInputContext = object : NativeTextInputContext { - override fun usingNativeTextInput(): Boolean = - currentInputConnection is NativeTextInputConnection + val textInputContainer by lazy(LazyThreadSafetyMode.NONE) { + object : TextInputContainer { + override fun createTextInput( + delegate: TextInputContainer.Delegate + ): TextInputContainer.Holder { + val connection = if (delegate.imeOptions.platformImeOptions?.usingNativeTextInput == true) { + NativeTextInputConnection( + inactiveTextInputDelegate = InactiveTextInputAdapter(delegate), + updateView = updateView, + coroutineScope = coroutineScope, + focusedViewsList = focusedViewsList, + focusManager = focusManager, + ) + } else { + ComposeTextInputConnection( + inactiveTextEditingDelegate = InactiveTextInputAdapter(delegate), + updateView = updateView, + coroutineScope = coroutineScope, + viewConfiguration = viewConfiguration, + focusedViewsList = focusedViewsList, + focusManager = focusManager + ) + } - override fun updateNativeTextInputEditMenuState( - copy: (() -> Unit)?, - paste: (() -> Unit)?, - cut: (() -> Unit)?, - selectAll: (() -> Unit)?, - customActions: List? - ) { - fun update() { - currentInputConnection?.setAvailableEditMenuActions( - copy = copy, - paste = paste, - cut = cut, - selectAll = selectAll, - customActions = customActions + this@TextInputService.view.addSubview(connection.rootView) + + val holder = TextInputHolder( + delegate = delegate, + connection = connection, + onRemove = { holders.remove(it) }, ) - updateEditMenuState = {} + + holders.add(holder) + + return holder } - if (currentInputConnection == null) { - // Fixes race conditions when the `updateNativeTextInputEditMenuState` called before - // the input session start. - updateEditMenuState = ::update - } else { - update() + override fun createSelectionContainer(delegate: TextInputContainer.Delegate): TextInputContainer.Holder { + val connection = SelectionContainerConnection( + coroutineScope = coroutineScope, + viewConfiguration = viewConfiguration, + focusManager = focusManager + ) + view.addSubview(connection.rootView) + val holder = TextInputHolder( + delegate = delegate, + connection = connection, + onRemove = { holders.remove(it) }, + ) + holders.add(holder) + + return holder } - } - override fun updateNativeTextInputTintColor(color: Color?) { - (currentInputConnection as? NativeTextInputConnection)?.updateNativeTextInputTintColor( - color - ) + override fun activeSessionUsesNativeTextInput(): Boolean = + currentInputConnection is NativeTextInputConnection } } fun dispose() { stopInput() + + holders.toList().forEach { it.remove() } + listener = EmptyListener updateView = {} focusManager = { null } } } + +private class TextInputHolder( + val delegate: TextInputContainer.Delegate, + val connection: TextInputConnection, + val onRemove: (TextInputHolder) -> Unit, +): TextInputContainer.Holder { + override fun setRect(rect: Rect) { + connection.rootView.setFrame(rect.toDpRect(connection.rootView.density).toCGRect()) + } + + override fun remove() { + connection.stop() + + onRemove(this) + + connection.rootView.resignFirstResponder() + connection.dispose() + + removeView() + } + + private fun removeView() { + // Out-of-bounds non-empty frame is required to hide text keyboard focus frame + val outOfBoundsFrame = CGRectMake(-100000.0, 0.0, 1.0, 1.0) + + val rootView = connection.rootView + rootView.setFrame(outOfBoundsFrame) + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, CLEAR_FOCUS_DELAY.inWholeMilliseconds * NSEC_PER_MSEC.toLong()), + dispatch_get_main_queue() + ) { + rootView.removeFromSuperview() + } + } + + override fun showEditMenuAtRect( + targetRect: Rect, + copy: (() -> Unit)?, + cut: (() -> Unit)?, + paste: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List? + ) { + val connection = connection as? ComposeTextInputConnection ?: return + connection.showToolbarMenu( + rect = targetRect, + onCopyRequested = copy, + onPasteRequested = paste, + onCutRequested = cut, + onSelectAllRequested = selectAll, + customActions = customActions, + ) + } + + override fun hideEditMenu() { + val connection = connection as? ComposeTextInputConnection ?: return + connection.hideToolbar() + } + + override fun updateNativeTextInputEditMenuState( + copy: (() -> Unit)?, + cut: (() -> Unit)?, + paste: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List? + ) { + connection.setAvailableEditMenuActions( + copy = copy, + cut = cut, + paste = paste, + selectAll = selectAll, + customActions = customActions, + ) + } + + override fun updateNativeTextInputTintColor(color: Color?) { + val connection = connection as? NativeTextInputConnection ?: return + connection.updateNativeTextInputTintColor(color) + } + + override fun usingNativeTextInput(): Boolean { + return delegate.imeOptions.platformImeOptions?.usingNativeTextInput ?: false + } +} \ No newline at end of file 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 6dbde0def2152..21d54d7515c4e 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 @@ -72,7 +72,7 @@ import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.uikit.InterfaceOrientation -import androidx.compose.ui.uikit.LocalNativeTextInputContext +import androidx.compose.ui.uikit.LocalTextInputContainer import androidx.compose.ui.uikit.LocalUIView import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.uikit.density @@ -446,7 +446,7 @@ internal class ComposeSceneMediator( ) } - private val textInputService: TextInputService by lazy { + private val textInputService = TextInputService( updateView = { frameChoreographer.performFrameIfNeeded() @@ -470,7 +470,6 @@ internal class ComposeSceneMediator( focusManager = { scene.focusManager }, coroutineContext = coroutineContext, ) - } private val textInputServiceAdapter by lazy { TextInputServiceAdapter( @@ -714,7 +713,7 @@ internal class ComposeSceneMediator( CompositionLocalProvider( LocalInteropContainer provides interopContainer, LocalUIView provides _overlayView, - LocalNativeTextInputContext provides textInputService.nativeTextInputContext, + LocalTextInputContainer provides textInputService.textInputContainer, content = content ) @@ -758,6 +757,8 @@ internal class ComposeSceneMediator( scene.close() interopContainer.dispose() semanticsOwnerListener.dispose() + + textInputService.dispose() } /** 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 5c07524a0d21b..48d5b13e01951 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,10 +17,12 @@ package androidx.compose.ui.text.input import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.platform.EmptyTextEditingDelegate +import androidx.compose.ui.platform.NativeTextEditingDelegate +import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.TextToolbarStatus import androidx.compose.ui.platform.NativeTextInputContextMenuCustomAction import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.platform.detachedCopy import androidx.compose.ui.scene.ComposeSceneFocusManager import androidx.compose.ui.uikit.density import androidx.compose.ui.uikit.utils.CMPEditMenuCustomAction @@ -32,26 +34,22 @@ import androidx.compose.ui.window.ComposeTextInputView import androidx.compose.ui.window.FocusedViewsList import kotlinx.cinterop.useContents import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import platform.CoreGraphics.CGRectMake import platform.UIKit.UIView import platform.UIKit.UIViewAutoresizingFlexibleHeight import platform.UIKit.UIViewAutoresizingFlexibleWidth internal open class ComposeTextInputConnection( + private var inactiveTextEditingDelegate: NativeTextEditingDelegate, updateView: () -> Unit, - view: UIView, coroutineScope: CoroutineScope, viewConfiguration: ViewConfiguration, focusedViewsList: FocusedViewsList?, focusManager: () -> ComposeSceneFocusManager? ) : TextInputConnection( - updateView, - view, - coroutineScope, - focusedViewsList, - focusManager + updateView = updateView, + coroutineScope = coroutineScope, + focusedViewsList = focusedViewsList, + focusManager = focusManager ) { // Fixes a problem where the menu is shown before the textInputView gets its final layout. private var showMenuOrUpdatePosition = {} @@ -61,36 +59,37 @@ internal open class ComposeTextInputConnection( override val textInputView = ComposeTextInputView( doubleTapTimeoutMillis = viewConfiguration.doubleTapTimeoutMillis, - input = EmptyTextEditingDelegate, + initialInput = inactiveTextEditingDelegate, ).also { it.setAutoresizingMask( UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight ) } - override fun attachInputToView() { - view.addSubview(textInputView) - textInputView.setFrame(view.bounds) + override val rootView: UIView get() = textInputView + override fun start(request: PlatformTextInputMethodRequest) { textInputView.input = this + + super.start(request) + onViewGeometryUpdated() } - override fun detachView() { - // Out-of-bounds non-empty frame is required to hide text keyboard focus frame - val outOfBoundsFrame = CGRectMake(-100000.0, 0.0, 1.0, 1.0) - - textInputView.input = EmptyTextEditingDelegate + override fun stop() { + super.stop() - showMenuOrUpdatePosition = {} - textInputView.let { view -> - view.setFrame(outOfBoundsFrame) - coroutineScope.launch { - delay(CLEAR_FOCUS_DELAY) - view.removeFromSuperview() - } - } + textInputView.input = inactiveTextEditingDelegate textInputView.updateAvailableSystemActions(null, null, null, null, null) + showMenuOrUpdatePosition = {} + } + + override fun dispose() { + super.dispose() + + // Keep answering for the text that was here, without holding the text field alive. + inactiveTextEditingDelegate = inactiveTextEditingDelegate.detachedCopy() + textInputView.input = inactiveTextEditingDelegate } override fun stateWillChange(textChanged: Boolean, selectionChanged: Boolean) { @@ -113,7 +112,8 @@ internal open class ComposeTextInputConnection( override fun onViewGeometryUpdated() { val rect = textFieldRectInRoot ?: return - textInputView.setFrame(rect.toDpRect(view.density).toCGRect()) + val density = textInputView.window?.density ?: return + textInputView.setFrame(rect.toDpRect(density).toCGRect()) showMenuOrUpdatePosition() } @@ -145,11 +145,12 @@ internal open class ComposeTextInputConnection( onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, onCutRequested: (() -> Unit)?, - onSelectAllRequested: (() -> Unit)? + onSelectAllRequested: (() -> Unit)?, + customActions: List?, ) { showMenuOrUpdatePosition = { syncTextFieldValueFromRequestSnapshot() - val density = view.density + val density = rootView.density val offset = textInputView.frame.useContents { origin.toDpOffset().toOffset(density) } val target = rect.translate(-offset).toDpRect(density).toCGRect() textInputView.showEditMenuAtRect( @@ -159,7 +160,7 @@ internal open class ComposeTextInputConnection( paste = onPasteRequested, select = null, selectAll = onSelectAllRequested, - customActions = emptyList() + customActions = customActions?.map { CMPEditMenuCustomAction(it.title, it.action) }, ) 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 90f9a14f77a85..03e56ef3dc1de 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 @@ -20,13 +20,14 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.DpInsets -import androidx.compose.ui.platform.EmptyTextEditingDelegate import androidx.compose.ui.platform.NativeTextEditingDelegate import androidx.compose.ui.platform.TextLayoutDirection import androidx.compose.ui.platform.TextInputSelectionRect import androidx.compose.ui.platform.NativeTextInputContextMenuCustomAction +import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.platform.toUIColor +import androidx.compose.ui.platform.detachedCopy import androidx.compose.ui.scene.ComposeSceneFocusManager import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange @@ -44,33 +45,33 @@ import androidx.compose.ui.window.NativeTextInputView import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import platform.CoreGraphics.CGRectMake import platform.UIKit.UIView +import platform.UIKit.reloadInputViews internal class NativeTextInputConnection( + private var inactiveTextInputDelegate: NativeTextEditingDelegate, updateView: () -> Unit, - view: UIView, coroutineScope: CoroutineScope, focusedViewsList: FocusedViewsList?, focusManager: () -> ComposeSceneFocusManager? ) : TextInputConnection( - updateView, - view, - coroutineScope, - focusedViewsList, - focusManager + updateView = updateView, + coroutineScope = coroutineScope, + focusedViewsList = focusedViewsList, + focusManager = focusManager ), NativeTextEditingDelegate { + override val isInteractive: Boolean = true + private val scrollView by lazy { NativeTextInputScrollView() } - override val isInteractive: Boolean = true + override val textInputView = NativeTextInputView(input = inactiveTextInputDelegate).also { + scrollView.textView = it + } - override val textInputView = NativeTextInputView(input = EmptyTextEditingDelegate) + override val rootView: UIView get() = scrollView - override fun attachInputToView() { - view.addSubview(scrollView) - scrollView.textView = textInputView + override fun start(request: PlatformTextInputMethodRequest) { + super.start(request) textInputView.input = this @@ -80,21 +81,18 @@ internal class NativeTextInputConnection( onViewGeometryUpdated() } - override fun detachView() { - // Out-of-bounds non-empty frame is required to hide text keyboard focus frame - val outOfBoundsFrame = CGRectMake(-100000.0, 0.0, 1.0, 1.0) + override fun stop() { + super.stop() - textInputView.input = EmptyTextEditingDelegate + textInputView.input = inactiveTextInputDelegate + } - textInputView.let { textView -> - textView.setFrame(outOfBoundsFrame) - coroutineScope.launch { - delay(CLEAR_FOCUS_DELAY) - scrollView.textView = null - textView.removeFromSuperview() - } - } - scrollView.removeFromSuperview() + override fun dispose() { + super.dispose() + + // Keep answering for the text that was here, without holding the text field alive. + inactiveTextInputDelegate = inactiveTextInputDelegate.detachedCopy() + textInputView.input = inactiveTextInputDelegate } override fun stateWillChange(textChanged: Boolean, selectionChanged: Boolean) { @@ -140,8 +138,8 @@ internal class NativeTextInputConnection( val contentInsets = calculateContentInsets(rect, contentBounds) currentContentInsets = contentInsets scrollView.setFrame( - rect.toDpRect(view.density), - contentBounds.toDpRect(view.density), + rect.toDpRect(rootView.density), + contentBounds.toDpRect(rootView.density), contentInsets ) } @@ -155,17 +153,21 @@ internal class NativeTextInputConnection( return contentBounds } - private fun calculateContentInsets(textFieldFrame: Rect, contentBounds: Rect): DpInsets = with(view.density) { - return DpInsets( - left = max(0f, -contentBounds.left).toDp(), - top = max(0f, -contentBounds.top).toDp(), - right = max(0f, textFieldFrame.width - contentBounds.width + contentBounds.left).toDp(), - bottom = max( - 0f, - textFieldFrame.height - contentBounds.height + contentBounds.top - ).toDp() - ) - } + private fun calculateContentInsets(textFieldFrame: Rect, contentBounds: Rect): DpInsets = + with(rootView.density) { + DpInsets( + left = max(0f, -contentBounds.left).toDp(), + top = max(0f, -contentBounds.top).toDp(), + right = max( + 0f, + textFieldFrame.width - contentBounds.width + contentBounds.left + ).toDp(), + bottom = max( + 0f, + textFieldFrame.height - contentBounds.height + contentBounds.top + ).toDp() + ) + } override fun caretDpRectForPosition(position: Int): DpRect? { val text = currentTextFieldValue?.text ?: return null @@ -177,7 +179,7 @@ internal class NativeTextInputConnection( return null } val rect = currentTextLayoutResult.getCursorRect(position) - return rect.toDpRect(view.density).let { + return rect.toDpRect(rootView.density).let { val halfWidth = cursorThickness / 2 val center = (it.left + it.right) / 2 it.copy(left = center - halfWidth, right = center + halfWidth) @@ -208,7 +210,7 @@ internal class NativeTextInputConnection( dpRect = Rect( topLeft = startSelectionHandleRect.topLeft, bottomRight = endSelectionHandleRect.bottomRight - ).toDpRect(view.density), + ).toDpRect(rootView.density), writingDirection = TextDirection.Content, containsStart = true, containsEnd = true, @@ -220,7 +222,7 @@ internal class NativeTextInputConnection( // We require separate rects for start line, end line and everything in between them val contentInsets = currentContentInsets ?: return emptyList() val contentRect = currentContentBounds?.let { - with(view.density) { + with(rootView.density) { Rect( top = it.top + contentInsets.top.toPx(), left = it.left + contentInsets.left.toPx(), @@ -236,7 +238,7 @@ internal class NativeTextInputConnection( left = startSelectionHandleRect.left, right = contentRect.right, bottom = startSelectionHandleRect.bottom - ).toDpRect(view.density), + ).toDpRect(rootView.density), writingDirection = TextDirection.Content, containsStart = true, containsEnd = false, @@ -249,7 +251,7 @@ internal class NativeTextInputConnection( left = contentRect.left, right = contentRect.right, bottom = endSelectionHandleRect.top - ).toDpRect(view.density), + ).toDpRect(rootView.density), writingDirection = TextDirection.Content, containsStart = false, containsEnd = false, @@ -263,7 +265,7 @@ internal class NativeTextInputConnection( dpRect = Rect( topLeft = lastLineStartRect.topLeft, bottomRight = endSelectionHandleRect.bottomRight - ).toDpRect(view.density), + ).toDpRect(rootView.density), writingDirection = TextDirection.Content, containsStart = false, containsEnd = true, @@ -297,7 +299,7 @@ internal class NativeTextInputConnection( Rect( topLeft = startHandleRect.topLeft, bottomRight = currentTextLayoutResult.getCursorRect(range.end).bottomRight - ).toDpRect(view.density) + ).toDpRect(rootView.density) } else { val startLineNumber = currentTextLayoutResult.getLineForOffset(range.start) val startLineRight = currentTextLayoutResult.getLineRight(startLineNumber) @@ -306,24 +308,24 @@ internal class NativeTextInputConnection( startHandleRect.top, startLineRight, startHandleRect.bottom - ).toDpRect(view.density) + ).toDpRect(rootView.density) } } override fun closestPositionToPoint(point: DpOffset): Int? { - return textLayoutResult?.getOffsetForPosition(point.toOffset(view.density)) + return textLayoutResult?.getOffsetForPosition(point.toOffset(rootView.density)) } override fun closestPositionToPoint(point: DpOffset, withinRange: TextRange): Int? { val pointOffset = - textLayoutResult?.getOffsetForPosition(point.toOffset(view.density)) + textLayoutResult?.getOffsetForPosition(point.toOffset(rootView.density)) ?: return null return pointOffset.coerceIn(withinRange.start, withinRange.end) } override fun characterRangeAtPoint(point: DpOffset): TextRange? { val pointOffset = - textLayoutResult?.getOffsetForPosition(point.toOffset(view.density)) + textLayoutResult?.getOffsetForPosition(point.toOffset(rootView.density)) ?: return null return textLayoutResult?.getWordBoundary(pointOffset) } 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 ba18398c59eae..669146462809d 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 @@ -16,24 +16,23 @@ package androidx.compose.ui.text.input +import androidx.compose.ui.platform.DetachedTextEditingDelegate import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.scene.ComposeSceneFocusManager import kotlinx.coroutines.CoroutineScope -import platform.UIKit.UIView internal class SelectionContainerConnection( - view: UIView, coroutineScope: CoroutineScope, viewConfiguration: ViewConfiguration, focusManager: () -> ComposeSceneFocusManager? ) : ComposeTextInputConnection( - {}, - view, - coroutineScope, - viewConfiguration, - null, - focusManager + inactiveTextEditingDelegate = DetachedTextEditingDelegate(), + updateView = {}, + coroutineScope = coroutineScope, + viewConfiguration = viewConfiguration, + focusedViewsList = null, + focusManager = focusManager ) { override fun showKeyboard() { // Does nothing. Keyboard is not needed for the selection container 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 9b26c5daca77b..7a42cc9cfd891 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 @@ -28,6 +28,8 @@ import androidx.compose.ui.platform.SkikoUITextInputTraits import androidx.compose.ui.platform.TextEditingDelegate import androidx.compose.ui.platform.NativeTextInputContextMenuCustomAction import androidx.compose.ui.platform.getUITextInputTraits +import androidx.compose.ui.platform.isValidIn +import androidx.compose.ui.platform.movePositionByGraphemes import androidx.compose.ui.scene.ComposeSceneFocusManager import androidx.compose.ui.text.TextRange import androidx.compose.ui.uikit.density @@ -38,19 +40,17 @@ import androidx.compose.ui.window.ComposeTextInputView import androidx.compose.ui.window.FocusedViewsList import androidx.compose.ui.window.NativeTextInputView import androidx.compose.ui.window.OverlayInputView -import kotlin.math.absoluteValue import kotlin.math.min import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jetbrains.skia.BreakIterator import platform.UIKit.UIView +import platform.UIKit.reloadInputViews internal abstract class TextInputConnection( - protected val updateView: () -> Unit, - protected val view: UIView, + protected var updateView: () -> Unit, protected val coroutineScope: CoroutineScope, protected val focusedViewsList: FocusedViewsList?, private var focusManager: () -> ComposeSceneFocusManager?, @@ -78,15 +78,22 @@ internal abstract class TextInputConnection( protected val unclippedTextOffsetInRoot get() = currentRequest?.unclippedTextOffsetInRoot() protected val textFieldRectInRoot get() = currentRequest?.textFieldRectInRoot() - fun start(request: PlatformTextInputMethodRequest) { + abstract val rootView: UIView + + open fun start(request: PlatformTextInputMethodRequest) { currentRequest = request currentTextFieldValue = request.stateSnapshot() + + val previousTraits = inputTraits inputTraits = getUITextInputTraits(request.imeOptions) - attachInputToView() showKeyboard() - } - protected abstract fun attachInputToView() + if (previousTraits.inputView() !== inputTraits.inputView() || + previousTraits.inputAccessoryView() !== inputTraits.inputAccessoryView() + ) { + reloadInputViews() + } + } open fun stop() { currentRequest = null @@ -94,11 +101,14 @@ internal abstract class TextInputConnection( inputTraits = EmptyInputTraits dismissKeyboard() - - detachView() } - protected abstract fun detachView() + open fun dispose() { + stop() + + updateView = {} + focusManager = { null } + } open fun showKeyboard() { focusedViewsList?.addAndFocus(textInputView) @@ -108,6 +118,8 @@ internal abstract class TextInputConnection( focusedViewsList?.remove(textInputView, delay = CLEAR_FOCUS_DELAY) } + fun reloadInputViews() = textInputView.reloadInputViews() + open fun onTextFieldValueUpdated(newValue: TextFieldValue) { if (postponeSelectionUpdate) { currentTextFieldValue = newValue @@ -283,7 +295,7 @@ internal abstract class TextInputConnection( } return view.subviews.any { it is UIView && hasFocusedExternalInputView(it) } } - return view.window?.let { hasFocusedExternalInputView(it) } ?: false + return rootView.window?.let { hasFocusedExternalInputView(it) } ?: false } override var inputTraits: SkikoUITextInputTraits = EmptyInputTraits @@ -302,7 +314,7 @@ internal abstract class TextInputConnection( val translation = floatingCursorTranslation ?: return val layout = textLayoutResult ?: return - val fingerPx = offset.toOffset(view.density) + val fingerPx = offset.toOffset(rootView.density) val virtualCursorPx = fingerPx + translation val cursorOffset = layout.getOffsetForPosition(virtualCursorPx) @@ -334,7 +346,7 @@ internal abstract class TextInputConnection( override fun beginFloatingCursor(offset: DpOffset) { val start = currentTextFieldValue?.selection?.start ?: return val cursorRect = textLayoutResult?.getCursorRect(start) ?: return - floatingCursorTranslation = cursorRect.center - offset.toOffset(view.density) + floatingCursorTranslation = cursorRect.center - offset.toOffset(rootView.density) } override fun endFloatingCursor() { @@ -420,36 +432,8 @@ internal abstract class TextInputConnection( } } - override fun positionFromPosition(position: Int, offset: Int): Int? { - val text = currentTextFieldValue?.text ?: return null - - val newPosition = position + offset - if (newPosition == text.length || newPosition == 0) { - return newPosition - } - if (newPosition < 0 || newPosition > text.length) { - return null - } - var resultPosition = position - val iterator = BreakIterator.makeCharacterInstance() - iterator.setText(text) - - repeat(offset.absoluteValue) { - val iteratorResult = if (offset > 0) { - iterator.following(resultPosition) - } else { - iterator.preceding(resultPosition) - } - - if (iteratorResult == BreakIterator.DONE) { - return resultPosition - } else { - resultPosition = iteratorResult - } - } - - return resultPosition - } + override fun positionFromPosition(position: Int, offset: Int): Int? = + currentTextFieldValue?.text?.movePositionByGraphemes(position, offset) override fun verticalPositionFromPosition(position: Int, verticalOffset: Int): Int? { val text = currentTextFieldValue?.text ?: return null @@ -481,9 +465,7 @@ internal abstract class TextInputConnection( } } - protected fun isIncorrect(range: TextRange): Boolean { - return range.start < 0 || range.end > endOfDocument() || range.start > range.end - } + protected fun isIncorrect(range: TextRange): Boolean = !range.isValidIn(endOfDocument()) companion object { // Due to unexpected delays between the commands to show/hide the keyboard, diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/UIKitCompositionLocals.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/UIKitCompositionLocals.ios.kt index ce67c298fe7bc..0c8193022fd69 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/UIKitCompositionLocals.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/UIKitCompositionLocals.ios.kt @@ -18,9 +18,8 @@ package androidx.compose.ui.uikit import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.InternalComposeUiApi -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.NativeTextInputContextMenuCustomAction -import androidx.compose.ui.platform.NativeTextInputContext +import androidx.compose.ui.platform.EmptyTextInputContainer +import androidx.compose.ui.platform.TextInputContainer import platform.UIKit.UIView import platform.UIKit.UIViewController @@ -44,19 +43,12 @@ val LocalUIView = staticCompositionLocalOf { error("CompositionLocal UIView not provided") } +/** + * CompositionLocal providing the current [TextInputContainer] instance. + * + * This is used internally within the Compose UI system to manage text input behavior, + * including creating and interacting with native or custom text input implementations. + */ @InternalComposeUiApi -val LocalNativeTextInputContext = staticCompositionLocalOf { - object : NativeTextInputContext { - override fun usingNativeTextInput(): Boolean = false - - override fun updateNativeTextInputEditMenuState( - copy: (() -> Unit)?, - paste: (() -> Unit)?, - cut: (() -> Unit)?, - selectAll: (() -> Unit)?, - customActions: List? - ) {} - - override fun updateNativeTextInputTintColor(color: Color?) {} - } -} \ No newline at end of file +val LocalTextInputContainer = + staticCompositionLocalOf { EmptyTextInputContainer } 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 5f93be3e0ded0..298ab23f609c9 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 @@ -76,11 +76,12 @@ import platform.darwin.NSInteger */ internal class ComposeTextInputView( private val doubleTapTimeoutMillis: Long, - input: TextEditingDelegate, + // Do not rename to `input`: shadowing the property below makes this view outlive its scene. + initialInput: TextEditingDelegate, ) : CMPEditMenuView(frame = CGRectZero.readValue()), UIKeyInputProtocol, UITextInputProtocol { private var _inputDelegate: UITextInputDelegateProtocol? = null - var input: TextEditingDelegate = input + var input: TextEditingDelegate = initialInput set(value) { field = value if (!value.isInteractive) { @@ -88,11 +89,14 @@ internal class ComposeTextInputView( } } - override fun canBecomeFirstResponder() = true + override fun canBecomeFirstResponder() = input.isInteractive - override fun isUserInteractionEnabled(): Boolean { - return false - } + override fun becomeFirstResponder(): Boolean = + if (input.isInteractive) { + super.becomeFirstResponder() + } else { + false + } override fun resignFirstResponder(): Boolean { input.onResignFocus() 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 b67ed160c25bf..4df870fc1b681 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 @@ -113,7 +113,7 @@ internal class NativeTextInputView( clipsToBounds = false } - override fun canBecomeFirstResponder() = true + override fun canBecomeFirstResponder() = input.isInteractive private val selectionInteraction = UITextInteraction.textInteractionForMode(UITextInteractionMode.UITextInteractionModeEditable) @@ -133,6 +133,9 @@ internal class NativeTextInputView( } override fun becomeFirstResponder(): Boolean { + if (!input.isInteractive) { + return false + } val isFirstResponder = this.isFirstResponder() val result = super.becomeFirstResponder() diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt index 94e33c355381a..c03130a2ba0ff 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt @@ -17,6 +17,7 @@ package androidx.compose.ui.platform import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.text.TextLayoutResult @@ -109,4 +110,11 @@ actual interface PlatformTextInputMethodRequest { */ @ExperimentalComposeUiApi val editText: (block: TextEditingScope.() -> Unit) -> Unit + + /** + * Custom token that uniquely identifies the text editor. + */ + @ExperimentalComposeUiApi + val editorToken: Any? + get() = null } 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 index 194e0e13aa1bb..21ed1a76607d2 100644 --- 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 @@ -20,6 +20,7 @@ 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.layout.size import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,6 +41,7 @@ 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.unit.dp import androidx.compose.ui.viewinterop.UIKitView import kotlin.test.Test import kotlin.test.assertEquals @@ -203,41 +205,42 @@ class KeyboardEventsTest { @Test fun simulateInterruptedKeyPressEvent() = runUIKitInstrumentedTest { - val requester1 = FocusRequester() - val requester2 = FocusRequester() + val textFieldRequester = FocusRequester() + val boxRequester = FocusRequester() val keyEvents = mutableListOf>() + var isTextFieldPresent by mutableStateOf(true) setContent { LaunchedEffect(Unit) { - requester1.requestFocus() + textFieldRequester.requestFocus() } Column( - modifier = Modifier.focusable().onPreviewKeyEvent { event -> - keyEvents += event.type to event.key - true - } + modifier = Modifier + .onPreviewKeyEvent { event -> + keyEvents += event.type to event.key + true + } ) { - BasicTextField( - value = "", - onValueChange = {}, - modifier = Modifier.focusRequester(requester1) - ) - BasicTextField( - value = "", - onValueChange = {}, - modifier = Modifier.focusRequester(requester2) - ) + if (isTextFieldPresent) { + BasicTextField( + value = "", + onValueChange = {}, + modifier = Modifier.focusRequester(textFieldRequester) + ) + } + Box(modifier = Modifier.size(20.dp).focusRequester(boxRequester).focusable()) } } - // Press 'x' and verify key down is dispatched to the onKeyEvent modifier + // 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() + boxRequester.requestFocus() + isTextFieldPresent = false waitForIdle() // Key up must be received when focus is removed (press is cancelled by UIKit) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt index bd68aa58f590e..81d70e8315927 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/NestedComposeTextFieldFocusTest.kt @@ -85,12 +85,14 @@ class NestedComposeTextFieldFocusTest { } findNodeWithTag(OuterFieldTag).tap() + waitForIdle() waitUntil("Outer text field should be focused after tap") { outerFocused && !nestedFocused } assertEquals(OuterFieldText, findFocusedUITextInput()?.text) findNodeWithTag(NestedFieldHostTag).tap() + waitForIdle() waitUntil("Nested text field should take focus and release the outer text field") { nestedFocused && !outerFocused } 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 3403630edfb80..1c1ebd43a1b90 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 @@ -282,7 +282,7 @@ class TextFieldEditMenuTest { @Test fun testEditableCollapsedClipboardText() = - runComplexTextFieldTest { textFieldKind, newContextMenu -> + runComplexTextFieldTest { textFieldKind, _ -> UIPasteboard.generalPasteboard().string = "Paste text" setTextFieldContent( textFieldKind = textFieldKind, @@ -291,21 +291,9 @@ class TextFieldEditMenuTest { ) longPressNodeWithTagAndAwaitContextMenu("TextField") - verifyContextMenuItemsVisible( - labels = if (newContextMenu) { - listOf("Paste", "Select All") - } else { - listOf("Paste", "Select", "Select All") - } - ) + verifyContextMenuItemsVisible(labels = listOf("Paste", "Select", "Select All")) - verifyContextMenuItemsHidden( - labels = if (newContextMenu) { - listOf("Cut", "Copy", "Select") - } else { - listOf("Cut", "Copy") - } - ) + verifyContextMenuItemsHidden(labels = listOf("Cut", "Copy")) } private fun runComplexTextFieldTest(test: UIKitInstrumentedTest.(BasicTextFieldType, newContextMenuEnabled: Boolean) -> Unit) { @@ -320,7 +308,7 @@ class TextFieldEditMenuTest { @Test fun testEditableCollapsedClipboardEmpty() = - runComplexTextFieldTest { textFieldKind, newContextMenu -> + runComplexTextFieldTest { textFieldKind, _ -> UIPasteboard.generalPasteboard().string = null setTextFieldContent( textFieldKind = textFieldKind, @@ -329,21 +317,9 @@ class TextFieldEditMenuTest { ) longPressNodeWithTagAndAwaitContextMenu("TextField") - verifyContextMenuItemsVisible( - labels = if (newContextMenu) { - listOf("Select All") - } else { - listOf("Select", "Select All") - } - ) + verifyContextMenuItemsVisible(listOf("Select", "Select All")) - verifyContextMenuItemsHidden( - labels = if (newContextMenu) { - listOf("Cut", "Copy", "Paste", "Select") - } else { - listOf("Cut", "Copy", "Paste") - } - ) + verifyContextMenuItemsHidden(labels = listOf("Cut", "Copy", "Paste")) } @Test @@ -460,8 +436,8 @@ class TextFieldEditMenuTest { ) longPressNodeWithTagAndAwaitContextMenu("TextField") - verifyContextMenuItemsVisible(labels = listOf("Select All")) - verifyContextMenuItemsHidden(labels = listOf("Cut", "Copy", "Paste", "Select")) + verifyContextMenuItemsVisible(labels = listOf("Select", "Select All")) + verifyContextMenuItemsHidden(labels = listOf("Cut", "Copy", "Paste")) } @Test diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldMagnifierTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldMagnifierTest.kt index e0962163e3b02..85d0a94b72010 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldMagnifierTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldMagnifierTest.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.NativeTextInputContext +import androidx.compose.ui.platform.TextInputContainer import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.findNodeWithTag import androidx.compose.ui.test.runUIKitInstrumentedTest @@ -39,7 +39,7 @@ import androidx.compose.ui.test.utils.isLoupeView import androidx.compose.ui.test.utils.up import androidx.compose.ui.text.input.PlatformImeOptions import androidx.compose.ui.unit.dp -import androidx.compose.ui.uikit.LocalNativeTextInputContext +import androidx.compose.ui.uikit.LocalTextInputContainer import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds @@ -61,10 +61,10 @@ class TextFieldMagnifierTest { params = params ) { factory -> val focusRequester = FocusRequester() - var nativeTextInputContext: NativeTextInputContext? = null + var nativeTextInputContext: TextInputContainer? = null setContent { - val currentNativeTextInputContext = LocalNativeTextInputContext.current + val currentNativeTextInputContext = LocalTextInputContainer.current SideEffect { nativeTextInputContext = currentNativeTextInputContext } @@ -80,7 +80,7 @@ class TextFieldMagnifierTest { assertEquals( expected = factory.useNativeTextInput, - actual = nativeTextInputContext?.usingNativeTextInput(), + actual = nativeTextInputContext?.activeSessionUsesNativeTextInput(), message = "Text input mode should be ${factory.textInputModeName}" ) @@ -96,10 +96,10 @@ class TextFieldMagnifierTest { params = params ) { factory -> val focusRequester = FocusRequester() - var nativeTextInputContext: NativeTextInputContext? = null + var nativeTextInputContext: TextInputContainer? = null setContent { - val currentNativeTextInputContext = LocalNativeTextInputContext.current + val currentNativeTextInputContext = LocalTextInputContainer.current SideEffect { nativeTextInputContext = currentNativeTextInputContext } @@ -115,7 +115,7 @@ class TextFieldMagnifierTest { assertEquals( expected = factory.useNativeTextInput, - actual = nativeTextInputContext?.usingNativeTextInput(), + actual = nativeTextInputContext?.activeSessionUsesNativeTextInput(), message = "Text input mode should be ${factory.textInputModeName}" ) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/ImeOptionsTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/ImeOptionsTest.kt index fc80328a38ce4..57794618dcfa0 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/ImeOptionsTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/ImeOptionsTest.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.PlatformTextInputSession import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.findAllUITextInputViews import androidx.compose.ui.test.findNodeWithTag import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.text.input.ImeAction @@ -539,23 +540,6 @@ internal class ImeOptionsTest { imeOptions: PlatformImeOptions? = null ): UITextInputProtocol = setContentAndFindInput(keyboardOptions = KeyboardOptions(platformImeOptions = imeOptions)) - private fun UIKitInstrumentedTest.findFirstUITextInput(): UIView? { - val windowScene = viewController.view.window?.windowScene ?: return null - - fun traverseSubviews(view: UIView): UIView? { - if (view as? UITextInputProtocol != null) { - return view - } - - view.subviews.forEach { - traverseSubviews(it as UIView)?.let { return it } - } - - return null - } - - return windowScene.windows.reversed().firstNotNullOfOrNull { - traverseSubviews(view = it as UIView) - } - } + private fun UIKitInstrumentedTest.findFirstUITextInput(): UIView? = + findAllUITextInputViews().firstOrNull() } \ No newline at end of file diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt new file mode 100644 index 0000000000000..aec21c4e620ff --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt @@ -0,0 +1,282 @@ +/* + * 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.keyboard + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.findAllUITextInputViews +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.text.input.PlatformImeOptions +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.cinterop.BetaInteropApi +import platform.Foundation.NSSelectorFromString +import platform.UIKit.UITextContentType +import platform.UIKit.UITextContentTypePassword +import platform.UIKit.UITextContentTypeUsername +import platform.UIKit.UITextField +import platform.UIKit.UITextInputProtocol +import platform.UIKit.UITextInputTraitsProtocol +import platform.UIKit.UIView + +internal class PasswordAutofillTest { + companion object { + private const val USERNAME = "user@example.com" + private const val PASSWORD = "hunter2" + } + + private data class Config( + val useNativeTextInput: Boolean, + val useBTF2: Boolean, + ) { + override fun toString(): String { + val backend = if (useNativeTextInput) "NativeTextInput" else "ComposeTextInput" + val generation = if (useBTF2) "BasicTextField2" else "BasicTextField" + return "$backend/$generation" + } + } + + private val configurations = listOf( + Config(useNativeTextInput = false, useBTF2 = false), + Config(useNativeTextInput = false, useBTF2 = true), + Config(useNativeTextInput = true, useBTF2 = false), + Config(useNativeTextInput = true, useBTF2 = true), + ) + + @Test + fun testBothCredentialInputViewsAreAttachedWhileOnlyOneIsFocused() = + runUIKitInstrumentedTest(params = configurations) { config -> + setCredentialsContent(config) + + val contentTypes = findAllUITextInputViews().map { + (it as UITextInputTraitsProtocol).textContentType + } + + assertEquals( + setOf(UITextContentTypeUsername, UITextContentTypePassword), + contentTypes.toSet(), + "$config: expected a username and a password input view attached at the same time, " + + "got $contentTypes." + ) + assertEquals(2, contentTypes.size, "$config: unexpected number of input views.") + } + + @Test + fun testUnfocusedPasswordInputViewExposesItsText() = + runUIKitInstrumentedTest(params = configurations) { config -> + setCredentialsContent(config) + + val passwordView = findUITextInputView(UITextContentTypePassword) + + assertFalse( + passwordView.isFirstResponder, + "$config: the password field was expected to stay unfocused." + ) + assertEquals( + PASSWORD, + passwordView.textInputDocumentText(), + "$config: the unfocused password input view didn't expose its text." + ) + } + + @OptIn(BetaInteropApi::class) + @Test + fun testSecureInputViewMasqueradesAsUITextField() = + runUIKitInstrumentedTest(params = configurations) { config -> + setCredentialsContent(config) + + assertTrue( + findUITextInputView(UITextContentTypePassword).isKindOfClass(UITextField), + "$config: the secure input view must report itself as a UITextField, otherwise iOS " + + "never offers to save the password." + ) + assertFalse( + findUITextInputView(UITextContentTypeUsername).isKindOfClass(UITextField), + "$config: only secure input views should masquerade as a UITextField." + ) + } + + @OptIn(BetaInteropApi::class) + @Test + fun testSecureInputViewTextSelectorReturnsWholeDocument() = + runUIKitInstrumentedTest(params = configurations) { config -> + setCredentialsContent(config) + + val text = findUITextInputView(UITextContentTypePassword) + .performSelector(NSSelectorFromString("text")) + + assertEquals( + PASSWORD, + text, + "$config: -text must return the whole document for a secure input view." + ) + } + + @Test + fun testInputViewsAreRemovedWhenTextFieldsLeaveComposition() = + runUIKitInstrumentedTest(params = configurations) { config -> + val visible = mutableStateOf(true) + + setContent { + if (visible.value) { + CredentialFields(config, focusRequester = null) + } + } + waitForIdle() + assertEquals(2, findAllUITextInputViews().size, "$config: input views weren't attached.") + + visible.value = false + waitForIdle() + + waitUntil("$config: input views outlived the text fields that own them.") { + findAllUITextInputViews().isEmpty() + } + } + + @OptIn(BetaInteropApi::class) + @Test + fun testDetachedCredentialViewsStillHoldTheirValues() = + runUIKitInstrumentedTest(params = configurations) { config -> + val visible = mutableStateOf(true) + + setContent { + if (visible.value) { + CredentialFields(config, focusRequester = null) + } + } + waitForIdle() + + val usernameView = findUITextInputView(UITextContentTypeUsername) + val passwordView = findUITextInputView(UITextContentTypePassword) + + visible.value = false + waitForIdle() + delay(1000) + + assertEquals( + USERNAME, + usernameView.textInputDocumentText(), + "$config: the detached username view lost its text." + ) + assertEquals( + PASSWORD, + passwordView.textInputDocumentText(), + "$config: the detached password view lost its text." + ) + assertEquals( + PASSWORD, + passwordView.performSelector(NSSelectorFromString("text")), + "$config: -text on the detached password view no longer returns the credential." + ) + } + + private fun UIKitInstrumentedTest.setCredentialsContent(config: Config) { + val focusRequester = FocusRequester() + + setContent { + CredentialFields(config, focusRequester) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } + + waitForIdle() + } + + @OptIn(ExperimentalComposeUiApi::class) + @Composable + private fun CredentialFields(config: Config, focusRequester: FocusRequester?) { + val usernameOptions = KeyboardOptions( + platformImeOptions = PlatformImeOptions { + textContentType(UITextContentTypeUsername) + isSecureTextEntry(false) + usingNativeTextInput(config.useNativeTextInput) + } + ) + val passwordOptions = KeyboardOptions( + platformImeOptions = PlatformImeOptions { + textContentType(UITextContentTypePassword) + isSecureTextEntry(true) + usingNativeTextInput(config.useNativeTextInput) + } + ) + val usernameModifier = focusRequester + ?.let { Modifier.focusRequester(it) } + ?: Modifier + + Column { + if (config.useBTF2) { + BasicTextField( + state = rememberTextFieldState(USERNAME), + modifier = usernameModifier, + keyboardOptions = usernameOptions, + ) + BasicTextField( + state = rememberTextFieldState(PASSWORD), + keyboardOptions = passwordOptions, + ) + } else { + BasicTextField( + value = USERNAME, + onValueChange = {}, + modifier = usernameModifier, + keyboardOptions = usernameOptions, + ) + BasicTextField( + value = PASSWORD, + onValueChange = {}, + keyboardOptions = passwordOptions, + ) + } + } + } + + private fun UIKitInstrumentedTest.findUITextInputView(contentType: UITextContentType): UIView { + val matching = findAllUITextInputViews().filter { + (it as UITextInputTraitsProtocol).textContentType == contentType + } + assertEquals( + 1, + matching.size, + "Expected exactly one text input view with content type $contentType, " + + "found ${matching.size}." + ) + return matching.single() + } + + private fun UIView.textInputDocumentText(): String? { + val input = this as UITextInputProtocol + val range = input.textRangeFromPosition( + fromPosition = input.beginningOfDocument, + toPosition = input.endOfDocument + ) ?: return null + return input.textInRange(range) + } +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/leaks/MemoryLeaksTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/leaks/MemoryLeaksTest.kt index 0c7034bf8bb82..b8ca9e8e8ee55 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/leaks/MemoryLeaksTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/leaks/MemoryLeaksTest.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.node.WeakReference -import androidx.compose.ui.platform.EmptyTextEditingDelegate import androidx.compose.ui.scene.ComposeHostingView import androidx.compose.ui.scene.ComposeHostingViewController import androidx.compose.ui.window.ComposeUIView @@ -39,7 +38,6 @@ import androidx.compose.ui.test.waitForIdle import androidx.compose.ui.uikit.embedSubview import androidx.compose.ui.window.ComposeUIViewController import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.ComposeTextInputView import kotlin.native.runtime.GC import kotlin.native.runtime.NativeRuntimeApi import kotlin.test.Test @@ -60,6 +58,7 @@ import platform.Foundation.NSRunLoop import platform.Foundation.dateWithTimeIntervalSinceNow import platform.Foundation.runUntilDate import platform.UIKit.UIApplication +import platform.UIKit.UITextField import platform.UIKit.UIView import platform.UIKit.UIViewController @@ -118,7 +117,7 @@ class MemoryLeaksTest { assertEquals( expected = 4, actual = subviewsReferences.count(), - message = "Expected 4 subviews: [ComposeView, UserInputView, MetalView, UIKitTransparentContainerView]" + + message = "Expected 4 subviews: [ComposeContainerView, BackgroundInputView, MetalView, OverlayInputView]" + ", but given: ${ subviewsReferences.mapNotNull { ref -> ref.get()?.let { it::class.simpleName } @@ -198,7 +197,7 @@ class MemoryLeaksTest { assertEquals( expected = 5, actual = subviewsReferences.count(), - message = "Expected 5 subviews: [ComposeView, UserInputView, MetalView, UIKitTransparentContainerView, IntermediateTextInputUIView]" + + message = "Expected 5 subviews: [ComposeContainerView, BackgroundInputView, MetalView, OverlayInputView, ComposeTextInputView]" + ", but given: ${ subviewsReferences.mapNotNull { ref -> ref.get()?.let { it::class.simpleName } @@ -248,9 +247,9 @@ class MemoryLeaksTest { ) assertEquals( - expected = 6, + expected = 5, actual = subviewsReferences.count(), - message = "Expected 6 subviews: [ComposeView, UserInputView, MetalView, UIKitTransparentContainerView, CMPEditMenuView, IntermediateTextInputUIView]" + + message = "Expected 5 subviews: [ComposeContainerView, BackgroundInputView, MetalView, OverlayInputView, ComposeTextInputView]" + ", but given: ${ subviewsReferences.mapNotNull { ref -> ref.get()?.let { it::class.simpleName } @@ -325,7 +324,7 @@ class MemoryLeaksTest { assertEquals( expected = 5, actual = subviewsReferences.count(), - message = "Expected 5 subviews: [ComposeHostingView, ComposeView, UserInputView, MetalView, UIKitTransparentContainerView]" + + message = "Expected 5 subviews: [ComposeHostingView, ComposeContainerView, BackgroundInputView, MetalView, OverlayInputView]" + ", but given: ${ subviewsReferences.mapNotNull { ref -> ref.get()?.let { it::class.simpleName } @@ -405,9 +404,9 @@ class MemoryLeaksTest { ) assertEquals( - expected = 7, + expected = 6, actual = subviewsReferences.count(), - message = "Expected 7 subviews: [ComposeHostingView, ComposeView, UserInputView, MetalView, UIKitTransparentContainerView, CMPEditMenuView, IntermediateTextInputUIView]" + + message = "Expected 6 subviews: [ComposeHostingView, ComposeContainerView, BackgroundInputView, MetalView, OverlayInputView, ComposeTextInputView]" + ", but given: ${ subviewsReferences.mapNotNull { ref -> ref.get()?.let { it::class.simpleName } @@ -425,7 +424,6 @@ class MemoryLeaksTest { } } - @OptIn(NativeRuntimeApi::class) @Test fun testComposeLayersViewControllerDisposal() = runBlocking { val appDelegate = MockAppDelegate() @@ -515,7 +513,6 @@ class MemoryLeaksTest { fail("Memory leak detected: references ${reference.mapNotNull { it.get() }} were not collected") } - @OptIn(NativeRuntimeApi::class) internal suspend fun assertDeallocated(reference: WeakReference<*>) { assertDeallocated(listOf(reference)) } @@ -538,12 +535,12 @@ class MemoryLeaksTest { secs = duration.toDouble(DurationUnit.SECONDS) ) ) - delay(duration.inWholeMilliseconds) + delay(duration) } @OptIn(ExperimentalForeignApi::class) - private fun startFakeTextInputSession(useNativeInput: Boolean = false) { - val input = ComposeTextInputView(0, EmptyTextEditingDelegate) + private fun startFakeTextInputSession() { + val input = UITextField() UIApplication.sharedApplication.keyWindow?.rootViewController?.view?.addSubview(input) input.setFrame(CGRectMake(0.0, 0.0, 100.0, 100.0)) input.becomeFirstResponder() 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 0a3811e79f96e..1b805f4112868 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 @@ -117,7 +117,6 @@ import platform.UIKit.UITouch import platform.UIKit.UITraitCollection import platform.UIKit.UITraitEnvironmentLayoutDirection import platform.UIKit.UITraitEnvironmentLayoutDirectionLeftToRight -import platform.UIKit.UITraitPreferredContentSizeCategory import platform.UIKit.UIUserInterfaceIdiomPad import platform.UIKit.UIView import platform.UIKit.UIViewController @@ -963,6 +962,21 @@ internal fun UIKitInstrumentedTest.findFocusedUITextInput(): UITextInputProtocol } as? UITextInputProtocol } +internal fun UIKitInstrumentedTest.findAllUITextInputViews(): List { + val windowScene = viewController.view.window?.windowScene ?: return emptyList() + + fun collect(view: UIView, into: MutableList) { + if (view is UITextInputProtocol) { + into.add(view) + } + view.subviews.forEach { collect(it as UIView, into) } + } + + return buildList { + windowScene.windows.reversed().forEach { collect(it as UIView, this) } + } +} + /** * A registry to track roots for testing purposes in the context of the platform UI. * Implements the `PlatformContext.RootForTestListener` interface to manage the lifecycle From 2e841a37ac06cf38f1fe41d4f7fef476c3832bb0 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 31 Aug 2026 18:10:40 +0200 Subject: [PATCH 02/12] Update comments --- .../compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt index c03130a2ba0ff..bd34c74b3c762 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt @@ -112,7 +112,7 @@ actual interface PlatformTextInputMethodRequest { val editText: (block: TextEditingScope.() -> Unit) -> Unit /** - * Custom token that uniquely identifies the text editor. + * Opaque token that uniquely identifies the text editor. */ @ExperimentalComposeUiApi val editorToken: Any? From c573159ff7c31d76603ef9b83d4cc99c8213637d Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 8 Sep 2026 11:35:41 +0200 Subject: [PATCH 03/12] Fix issues --- .../compose/ui/text/input/ComposeTextInputConnection.ios.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8bc2c93aef83e..897e400b535be 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 @@ -125,7 +125,7 @@ internal open class ComposeTextInputConnection( } val offset = textOffsetInRoot - viewOriginInRoot val rect = currentTextLayoutResult.getCursorRect(position).translate(offset) - return rect.toDpRect(view.density).let { + return rect.toDpRect(rootView.density).let { val halfWidth = CURSOR_THICKNESS / 2 val center = (it.left + it.right) / 2 it.copy(left = center - halfWidth, right = center + halfWidth) From f5767771e73ac85dcd564d80bc2699fff10e617e Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Wed, 9 Sep 2026 10:00:36 +0200 Subject: [PATCH 04/12] Update compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputContainer.ios.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Vendula Švastalová --- .../androidx/compose/ui/platform/TextInputContainer.ios.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputContainer.ios.kt index f16c0f49897f8..0941eb7a5739b 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputContainer.ios.kt @@ -62,7 +62,7 @@ interface TextInputContainer { /** * Provides a temporary connection between non-editable text field and iOS text input. - * Used to support the auto-safe/autofill password feature. + * Used to support the auto-save/autofill password feature. */ interface Delegate { val text: String From 8815e93c966ec8f57fa3259f3f5fa90ea3e3bd3b Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Wed, 9 Sep 2026 11:10:13 +0200 Subject: [PATCH 05/12] Reload holder when updating nativeTextInputChanged flag, PR Comments --- .../foundation/text/BasicTextField.ios.kt | 28 ++++++--- .../foundation/text/CoreTextField.ios.kt | 10 ++++ .../compose/mpp/demo/IosSpecificFeatures.kt | 2 +- ...kt => AutoSaveLoginPasswordExample.ios.kt} | 2 +- .../textfield/NativeTextInputExamples.ios.kt | 57 +++++++++++++++++++ 5 files changed, 89 insertions(+), 10 deletions(-) rename compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/{AutoSafeLoginPasswordExample.ios.kt => AutoSaveLoginPasswordExample.ios.kt} (99%) diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt index 788f3b9347f3c..95218fe7b7008 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.text.input.setSelectionCoerced import androidx.compose.foundation.text.input.internal.TransformedTextFieldState import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect @@ -37,6 +38,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.TextInputContainer import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.text.input.usingNativeTextInput import androidx.compose.ui.uikit.LocalTextInputContainer import androidx.compose.ui.unit.Density import kotlinx.coroutines.Job @@ -46,22 +48,22 @@ internal actual fun Modifier.textFieldOverlay( transformedState: TransformedTextFieldState, keyboardOptions: KeyboardOptions, interactionSource: InteractionSource -): Modifier = this then BasicTextFieldImeOverlayElement(transformedState, keyboardOptions, interactionSource) +): Modifier = this then BasicTextFieldOverlayElement(transformedState, keyboardOptions, interactionSource) -private data class BasicTextFieldImeOverlayElement( +private data class BasicTextFieldOverlayElement( private val transformedState: TransformedTextFieldState, private val keyboardOptions: KeyboardOptions, private val interactionSource: InteractionSource, -) : ModifierNodeElement() { +) : ModifierNodeElement() { - override fun create() = BasicTextFieldImeOverlayNode(transformedState, keyboardOptions, interactionSource) + override fun create() = BasicTextFieldOverlayNode(transformedState, keyboardOptions, interactionSource) - override fun update(node: BasicTextFieldImeOverlayNode) { + override fun update(node: BasicTextFieldOverlayNode) { node.update(transformedState, keyboardOptions, interactionSource) } override fun InspectorInfo.inspectableProperties() { - name = "basicTextFieldImeOverlay" + name = "basicTextFieldOverlay" properties["transformedState"] = transformedState properties["keyboardOptions"] = keyboardOptions properties["interactionSource"] = interactionSource @@ -69,7 +71,7 @@ private data class BasicTextFieldImeOverlayElement( } @OptIn(InternalComposeUiApi::class) -private class BasicTextFieldImeOverlayNode( +private class BasicTextFieldOverlayNode( private var transformedState: TransformedTextFieldState, keyboardOptions: KeyboardOptions, private var interactionSource: InteractionSource, @@ -112,6 +114,7 @@ private class BasicTextFieldImeOverlayNode( density = null } + @OptIn(ExperimentalComposeUiApi::class) fun update( transformedState: TransformedTextFieldState, keyboardOptions: KeyboardOptions, @@ -126,7 +129,16 @@ private class BasicTextFieldImeOverlayNode( delegate.transformedState = transformedState observeMirroredState() } - delegate.imeOptions = keyboardOptions.toImeOptions() + val newImeOptions = keyboardOptions.toImeOptions() + val nativeTextInputChanged = delegate.imeOptions.platformImeOptions?.usingNativeTextInput != + newImeOptions.platformImeOptions?.usingNativeTextInput + + delegate.imeOptions = newImeOptions + + if (nativeTextInputChanged) { + removeTextInput() + createTextInput() + } if (this.interactionSource != interactionSource) { this.interactionSource = interactionSource diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt index e0305c9412e7d..795317ad53441 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt @@ -19,6 +19,7 @@ package androidx.compose.foundation.text import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.composed @@ -52,6 +53,7 @@ import androidx.compose.ui.text.input.SetComposingRegionCommand import androidx.compose.ui.text.input.SetComposingTextCommand import androidx.compose.ui.text.input.SetSelectionCommand import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.usingNativeTextInput import androidx.compose.ui.uikit.LocalTextInputContainer import androidx.compose.ui.unit.Density @@ -247,6 +249,7 @@ private class CoreTextFieldImeOverlayNode( density = null } + @OptIn(ExperimentalComposeUiApi::class) fun update(state: LegacyTextFieldState, imeOptions: ImeOptions, interactionSource: InteractionSource?) { if (this.state !== state) { this.state.holder = null @@ -254,8 +257,15 @@ private class CoreTextFieldImeOverlayNode( state.holder = holder delegate.state = state } + val nativeTextInputChanged = delegate.imeOptions.platformImeOptions?.usingNativeTextInput != + imeOptions.platformImeOptions?.usingNativeTextInput delegate.imeOptions = imeOptions delegate.interactionSource = interactionSource + + if (nativeTextInputChanged) { + removeTextInput() + createTextInput() + } } override fun onObservedReadsChanged() { diff --git a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeatures.kt b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeatures.kt index 1024709eb2aea..d01bfd6e5c721 100644 --- a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeatures.kt +++ b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/IosSpecificFeatures.kt @@ -30,5 +30,5 @@ val IosSpecificFeatures = Screen.Selection( UpdatableInteropPropertiesExample, IosImeOptionsExample, NativeTextInputTextFields, - AutoSafeLoginPasswordExample, + AutoSaveLoginPasswordExample, ) \ No newline at end of file diff --git a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSafeLoginPasswordExample.ios.kt b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSaveLoginPasswordExample.ios.kt similarity index 99% rename from compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSafeLoginPasswordExample.ios.kt rename to compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSaveLoginPasswordExample.ios.kt index d9f02cde770d0..629d1780d463e 100644 --- a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSafeLoginPasswordExample.ios.kt +++ b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSaveLoginPasswordExample.ios.kt @@ -67,7 +67,7 @@ import platform.UIKit.UITextField import platform.UIKit.UITextSpellCheckingType import platform.UIKit.endEditing -val AutoSafeLoginPasswordExample = Screen.Selection( +val AutoSaveLoginPasswordExample = Screen.Selection( title = "Autosave Login & Password", screens = listOf( Screen.Fullscreen("UITextField safe password") { back -> UITextFieldSafePassword(back) }, diff --git a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/NativeTextInputExamples.ios.kt b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/NativeTextInputExamples.ios.kt index e991df6726d48..dcc0f054e2251 100644 --- a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/NativeTextInputExamples.ios.kt +++ b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/NativeTextInputExamples.ios.kt @@ -33,13 +33,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button import androidx.compose.material.Icon import androidx.compose.material.SecureTextField import androidx.compose.material.Text @@ -65,6 +68,9 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PlatformImeOptions import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UITextContentTypeUsername @OptIn(ExperimentalComposeUiApi::class) private val enabledNativeTextInputOptions = PlatformImeOptions { @@ -85,6 +91,7 @@ val NativeTextInputTextFields = Screen.Selection( Screen.Example("GraphicsLayer") { GraphicsLayer() }, Screen.Example("Appearance modifiers") { AppearanceModifiers() }, Screen.Example("Secure input") { SecureInput() }, + Screen.Example("Native Text Input Hot Switch") { NativeTextInputHotSwitchExample() }, ) ) @@ -530,3 +537,53 @@ private fun SecureInput() { } } } + +@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class, ExperimentalComposeUiApi::class) +@Composable +private fun NativeTextInputHotSwitchExample() { + var usingNative by remember { mutableStateOf(false) } + var text1 by remember { mutableStateOf("") } + val text2 = rememberTextFieldState() + + Column(Modifier.padding(16.dp).safeDrawingPadding()) { + Text("Native Text Input: ${usingNative}") + Spacer(Modifier.height(16.dp)) + + Text("BTF1:") + Spacer(Modifier.height(4.dp)) + TextField( + value = text1, + onValueChange = { text1 = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Text") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + platformImeOptions = PlatformImeOptions { + usingNativeTextInput(usingNative) + } + ) + ) + + Spacer(Modifier.height(16.dp)) + + Text("BTF2:") + Spacer(Modifier.height(4.dp)) + TextField( + state = text2, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Text") }, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions( + platformImeOptions = PlatformImeOptions { + usingNativeTextInput(usingNative) + } + ) + ) + + Spacer(Modifier.height(16.dp)) + + Button(onClick = { usingNative = !usingNative },) { + Text(if (usingNative) "Turn OFF" else "Turn ON") + } + } +} From 45bd279a0bc30098526789b6cf06ff4a1a37f470 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Wed, 9 Sep 2026 14:30:22 +0200 Subject: [PATCH 06/12] Fix tests --- .../androidx/compose/ui/platform/TextInputService.ios.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt index f38dfc0ccdd63..80625bdac05d9 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt @@ -85,9 +85,8 @@ internal class TextInputService( private val toolbarConnection: ComposeTextInputConnection? get() = currentInputConnection as? ComposeTextInputConnection + ?: holders.firstOrNull { it.delegate.isFocused }?.connection as? ComposeTextInputConnection ?: selectionContainerConnection - ?: holders.firstOrNull { it.delegate.isFocused } - ?.let { it.connection as? ComposeTextInputConnection } val hasInvalidations: Boolean get() = currentInputConnection?.hasInvalidations @@ -174,7 +173,7 @@ internal class TextInputService( onCutRequested: (() -> Unit)?, onSelectAllRequested: (() -> Unit)? ) { - if (currentInputConnection == null && selectionContainerConnection == null) { + if (toolbarConnection == null) { // Entry point for showing the context menu in SelectionContainer scenarios, where // there is no active text input session. iOS requires a UIView that can become first // responder in order to host the context menu, so we create a dedicated connection From 21f3e7783aabd51f7de7466afdfa593f4850df83 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Wed, 9 Sep 2026 17:20:28 +0200 Subject: [PATCH 07/12] Fix saving password for secured text fields --- .../foundation/text/BasicTextField.ios.kt | 40 +++++++++++-------- .../ui/keyboard/PasswordAutofillTest.kt | 7 +++- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt index 95218fe7b7008..117ac8fdb97ab 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt @@ -190,7 +190,9 @@ private class BasicTextFieldOverlayNode( private fun observeMirroredState() { stateObserverJob?.cancel() stateObserverJob = coroutineScope.launch { - snapshotFlow { transformedState.visualText.let { it.toString() to it.selection } } + snapshotFlow { + transformedState.untransformedText.let { it.toString() to it.selection } + } .collect { delegate.refreshValue() } @@ -230,26 +232,23 @@ private class BasicTextFieldInputDelegate( override var isFocused: Boolean = false - private val visualText - get() = transformedState.visualText + private val untransformedText + get() = transformedState.untransformedText - /** - * The last known state of the text field. - */ - override var text: String = visualText.toString() + override var text: String = untransformedText.toString() private set - override var selectionTextRange: TextRange = visualText.selection + override var selectionTextRange: TextRange = untransformedText.selection private set - override var markedTextRange: TextRange? = visualText.composition + override var markedTextRange: TextRange? = transformedState.untransformedComposition private set fun refreshValue() { - val visualText = visualText - text = visualText.toString() - selectionTextRange = visualText.selection - markedTextRange = visualText.composition + val untransformedText = untransformedText + text = untransformedText.toString() + selectionTextRange = untransformedText.selection + markedTextRange = transformedState.untransformedComposition } private inline fun edit(block: () -> Unit) { @@ -262,20 +261,27 @@ private class BasicTextFieldInputDelegate( } override fun replaceRange(range: TextRange, text: String) = edit { - transformedState.replaceText(text, range) + replaceUntransformedText(range, text) } override fun deleteBackward() = edit { - val selection = visualText.selection + val selection = untransformedText.selection if (!selection.collapsed) { transformedState.deleteSelectedText() } else if (selection.min > 0) { - transformedState.replaceText("", TextRange(selection.min - 1, selection.max)) + replaceUntransformedText(TextRange(selection.min - 1, selection.max), "") } } override fun setSelectedText(range: TextRange?) = edit { - transformedState.selectCharsIn(range ?: TextRange(visualText.length)) + transformedState.selectUntransformedCharsIn(range ?: TextRange(untransformedText.length)) + } + + private fun replaceUntransformedText(range: TextRange, newText: String) { + transformedState.editUntransformedTextAsUser { + replace(range.min, range.max, newText) + setSelectionCoerced(range.min + newText.length) + } } override fun setMarkedText(markedText: String?, selectedRange: TextRange) { diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt index aec21c4e620ff..0e7c00519a300 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt @@ -17,8 +17,10 @@ package androidx.compose.ui.keyboard import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicSecureTextField import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextObfuscationMode import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,6 +32,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.test.UIKitInstrumentedTest import androidx.compose.ui.test.findAllUITextInputViews import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PlatformImeOptions import kotlin.test.Test import kotlin.test.assertEquals @@ -238,9 +241,10 @@ internal class PasswordAutofillTest { modifier = usernameModifier, keyboardOptions = usernameOptions, ) - BasicTextField( + BasicSecureTextField( state = rememberTextFieldState(PASSWORD), keyboardOptions = passwordOptions, + textObfuscationMode = TextObfuscationMode.Hidden, ) } else { BasicTextField( @@ -253,6 +257,7 @@ internal class PasswordAutofillTest { value = PASSWORD, onValueChange = {}, keyboardOptions = passwordOptions, + visualTransformation = PasswordVisualTransformation(), ) } } From 9726383788f5300f71afb45a1e639234e8b97224 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Wed, 9 Sep 2026 17:21:02 +0200 Subject: [PATCH 08/12] Add optins --- .../androidx/compose/ui/keyboard/PasswordAutofillTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt index 0e7c00519a300..f58bf39fb9747 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.kt @@ -39,6 +39,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSSelectorFromString import platform.UIKit.UITextContentType import platform.UIKit.UITextContentTypePassword @@ -125,7 +126,7 @@ internal class PasswordAutofillTest { ) } - @OptIn(BetaInteropApi::class) + @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) @Test fun testSecureInputViewTextSelectorReturnsWholeDocument() = runUIKitInstrumentedTest(params = configurations) { config -> @@ -162,7 +163,7 @@ internal class PasswordAutofillTest { } } - @OptIn(BetaInteropApi::class) + @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) @Test fun testDetachedCredentialViewsStillHoldTheirValues() = runUIKitInstrumentedTest(params = configurations) { config -> From 60faaf61e3bd3310a999aa78886c945a655ccfcd Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 11 Sep 2026 11:27:37 +0200 Subject: [PATCH 09/12] Fix issue with stale input traits --- .../compose/ui/text/input/TextInputConnection.ios.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 42569278378af..7ba4e04f074b5 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 @@ -82,17 +82,15 @@ internal abstract class TextInputConnection( abstract val rootView: UIView open fun start(request: PlatformTextInputMethodRequest) { + val inputViewWasFirstResponder = textInputView.isFirstResponder currentRequest = request currentTextFieldValue = request.stateSnapshot() - val previousTraits = inputTraits inputTraits = getUITextInputTraits(request.imeOptions) showKeyboard() - if (previousTraits.inputView() !== inputTraits.inputView() || - previousTraits.inputAccessoryView() !== inputTraits.inputAccessoryView() - ) { - reloadInputViews() + if (inputViewWasFirstResponder) { + textInputView.reloadInputViews() } } From 631989271bae3e81a73638f34002c9e68b8d02b8 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 11 Sep 2026 11:41:20 +0200 Subject: [PATCH 10/12] Fix issue with double context menu --- .../kotlin/androidx/compose/ui/platform/TextInputService.ios.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt index 80625bdac05d9..5bc1c53323514 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt @@ -173,7 +173,7 @@ internal class TextInputService( onCutRequested: (() -> Unit)?, onSelectAllRequested: (() -> Unit)? ) { - if (toolbarConnection == null) { + if (currentInputConnection == null && selectionContainerConnection == null) { // Entry point for showing the context menu in SelectionContainer scenarios, where // there is no active text input session. iOS requires a UIView that can become first // responder in order to host the context menu, so we create a dedicated connection From 920ffb0b99a244baaf6aab562f2f4d1adc74f2b9 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 11 Sep 2026 13:15:45 +0200 Subject: [PATCH 11/12] Fix issues text field selection --- .../foundation/text/CoreTextField.ios.kt | 2 +- .../foundation/text/TextInputHolder.ios.kt | 34 +++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt index 795317ad53441..d1a965960bc17 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/CoreTextField.ios.kt @@ -166,7 +166,7 @@ private class TextFieldDrawNode( drawIntoCanvas { canvas -> // iOS handles selection drawing itself in native text input mode // still needs this for text rendering - if (usingNativeTextInput) { + if (usingNativeTextInput || !state.hasFocus) { TextPainter.paint(canvas, layoutResult.value) } else { TextFieldDelegate.draw( diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextInputHolder.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextInputHolder.ios.kt index c4b1ace4a2934..951f6c44a0d89 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextInputHolder.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextInputHolder.ios.kt @@ -20,6 +20,8 @@ import androidx.compose.foundation.text.input.internal.TransformedTextFieldState import androidx.compose.foundation.text.selection.DefaultTextSelectionColors import androidx.compose.foundation.text.selection.SelectionManager import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.TextInputContainer @@ -58,18 +60,30 @@ internal var SelectionManager.holder: TextInputContainer.Holder? private val TextInputHolderAssociationKey: COpaquePointer = nativeHeap.alloc().ptr /** Associated object storage, since the common text field states can't declare an iOS-only field. */ -@OptIn(ExperimentalForeignApi::class, InternalComposeUiApi::class) +@OptIn(InternalComposeUiApi::class) private var Any.textInputHolder: TextInputContainer.Holder? - get() = - objc_getAssociatedObject(this, TextInputHolderAssociationKey) - as? TextInputContainer.Holder + get() = textInputHolderState.value set(value) { - objc_setAssociatedObject( - this, - TextInputHolderAssociationKey, - value, - OBJC_ASSOCIATION_RETAIN - ) + textInputHolderState.value = value + } + +@Suppress("UNCHECKED_CAST") +@OptIn(ExperimentalForeignApi::class, InternalComposeUiApi::class) +private val Any.textInputHolderState: MutableState + get() { + val existingState = + objc_getAssociatedObject(this, TextInputHolderAssociationKey) + as? MutableState + + return existingState + ?: mutableStateOf(null).also { state -> + objc_setAssociatedObject( + this, + TextInputHolderAssociationKey, + state, + OBJC_ASSOCIATION_RETAIN + ) + } } @OptIn(InternalComposeUiApi::class) From d83e5f229ecb0b9fe3879254621905a951364dbc Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 11 Sep 2026 17:39:59 +0200 Subject: [PATCH 12/12] PR comments --- .../androidx/compose/ui/platform/TextInputService.ios.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt index 5bc1c53323514..d1d732c4ae18c 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/TextInputService.ios.kt @@ -124,7 +124,7 @@ internal class TextInputService( } private fun startInput(request: PlatformTextInputMethodRequest) { - currentInputConnection?.stop() + stopInput() stopSelectionContainerConnection() listener.onInputWillStart() @@ -139,12 +139,14 @@ internal class TextInputService( } private fun stopInput() { + if (currentInputConnection == null) { + return + } currentInputConnection?.stop() currentInputConnection = null listener.onInputDidStop() } private fun stopSelectionContainerConnection() { - // TODO: Verify selectionContainerConnection?.stop() selectionContainerConnection?.rootView?.removeFromSuperview() selectionContainerConnection?.dispose()