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..117ac8fdb97ab --- /dev/null +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicTextField.ios.kt @@ -0,0 +1,306 @@ +/* + * 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.ExperimentalComposeUiApi +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.text.input.usingNativeTextInput +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 BasicTextFieldOverlayElement(transformedState, keyboardOptions, interactionSource) + +private data class BasicTextFieldOverlayElement( + private val transformedState: TransformedTextFieldState, + private val keyboardOptions: KeyboardOptions, + private val interactionSource: InteractionSource, +) : ModifierNodeElement() { + + override fun create() = BasicTextFieldOverlayNode(transformedState, keyboardOptions, interactionSource) + + override fun update(node: BasicTextFieldOverlayNode) { + node.update(transformedState, keyboardOptions, interactionSource) + } + + override fun InspectorInfo.inspectableProperties() { + name = "basicTextFieldOverlay" + properties["transformedState"] = transformedState + properties["keyboardOptions"] = keyboardOptions + properties["interactionSource"] = interactionSource + } +} + +@OptIn(InternalComposeUiApi::class) +private class BasicTextFieldOverlayNode( + 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 + } + + @OptIn(ExperimentalComposeUiApi::class) + 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() + } + 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 + 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.untransformedText.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 untransformedText + get() = transformedState.untransformedText + + override var text: String = untransformedText.toString() + private set + + override var selectionTextRange: TextRange = untransformedText.selection + private set + + override var markedTextRange: TextRange? = transformedState.untransformedComposition + private set + + fun refreshValue() { + val untransformedText = untransformedText + text = untransformedText.toString() + selectionTextRange = untransformedText.selection + markedTextRange = transformedState.untransformedComposition + } + + 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 { + replaceUntransformedText(range, text) + } + + override fun deleteBackward() = edit { + val selection = untransformedText.selection + if (!selection.collapsed) { + transformedState.deleteSelectedText() + } else if (selection.min > 0) { + replaceUntransformedText(TextRange(selection.min - 1, selection.max), "") + } + } + + override fun setSelectedText(range: TextRange?) = edit { + 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) { + 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..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 @@ -16,28 +16,46 @@ 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.ExperimentalComposeUiApi 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.text.input.usingNativeTextInput +import androidx.compose.ui.uikit.LocalTextInputContainer +import androidx.compose.ui.unit.Density @OptIn(InternalComposeUiApi::class) internal actual fun Modifier.textFieldCursor( @@ -47,22 +65,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 +133,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() @@ -150,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( @@ -167,4 +183,200 @@ 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 + } + + @OptIn(ExperimentalComposeUiApi::class) + 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 + } + val nativeTextInputChanged = delegate.imeOptions.platformImeOptions?.usingNativeTextInput != + imeOptions.platformImeOptions?.usingNativeTextInput + delegate.imeOptions = imeOptions + delegate.interactionSource = interactionSource + + if (nativeTextInputChanged) { + removeTextInput() + createTextInput() + } + } + + 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..951f6c44a0d89 --- /dev/null +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextInputHolder.ios.kt @@ -0,0 +1,94 @@ +/* + * 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.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +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(InternalComposeUiApi::class) +private var Any.textInputHolder: TextInputContainer.Holder? + get() = textInputHolderState.value + set(value) { + 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) +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 83434a5cce905..3f68cd97390c1 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 97% 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 b0bbbf241e1d5..3bce0f5455c84 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,5 +30,6 @@ val IosSpecificFeatures = Screen.Selection( UpdatableInteropPropertiesExample, IosImeOptionsExample, NativeTextInputTextFields, + AutoSaveLoginPasswordExample, PanPinchCircleExample, ) diff --git a/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSaveLoginPasswordExample.ios.kt b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSaveLoginPasswordExample.ios.kt new file mode 100644 index 0000000000000..629d1780d463e --- /dev/null +++ b/compose/mpp/demo/src/iosMain/kotlin/androidx/compose/mpp/demo/textfield/AutoSaveLoginPasswordExample.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 AutoSaveLoginPasswordExample = 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/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") + } + } +} 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 eeceff6627753..7263120180fe6 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 @@ -95,9 +95,15 @@ @interface CMPEditMenuView() - (void)dismissEditMenu; +/// 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; @@ -196,6 +202,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; +} + - (BOOL)isEditMenuShown { if (@available(iOS 16, *)) { return _editMenuState == CMPEditMenuStatePresenting || _editMenuState == CMPEditMenuStatePresented; 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..0941eb7a5739b --- /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-save/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 c70dc99c63e55..0d147b634571d 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 @@ -220,11 +221,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 @@ -233,21 +236,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 @@ -257,7 +261,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 @@ -280,6 +285,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 dac155938e67f..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 @@ -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( @@ -74,15 +84,17 @@ internal class TextInputService( private var selectionContainerConnection: SelectionContainerConnection? = null private val toolbarConnection: ComposeTextInputConnection? - get() = currentInputConnection as? ComposeTextInputConnection ?: selectionContainerConnection - - private var updateEditMenuState = {} + get() = currentInputConnection as? ComposeTextInputConnection + ?: holders.firstOrNull { it.delegate.isFocused }?.connection as? ComposeTextInputConnection + ?: selectionContainerConnection val hasInvalidations: Boolean get() = currentInputConnection?.hasInvalidations ?: selectionContainerConnection?.hasInvalidations ?: false + private val holders = mutableSetOf() + suspend fun startInputMethod(request: PlatformTextInputMethodRequest): Nothing { coroutineScope { launch { @@ -112,41 +124,32 @@ internal class TextInputService( } private fun startInput(request: PlatformTextInputMethodRequest) { - val usingNativeTextInput = request.imeOptions.platformImeOptions?.usingNativeTextInput ?: false - - currentInputConnection?.stop() + stopInput() stopSelectionContainerConnection() 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() { + if (currentInputConnection == null) { + return + } currentInputConnection?.stop() currentInputConnection = null listener.onInputDidStop() } private fun stopSelectionContainerConnection() { selectionContainerConnection?.stop() + selectionContainerConnection?.rootView?.removeFromSuperview() + selectionContainerConnection?.dispose() selectionContainerConnection = null } fun showSoftwareKeyboard() { @@ -180,41 +183,15 @@ internal class TextInputService( // Note: start() is intentionally not called here — it establishes a text editing // session (requiring a PlatformTextInputMethodRequest) which is not applicable for // SelectionContainer. - selectionContainerConnection = SelectionContainerConnection( - view = view, - coroutineScope = coroutineScope, - viewConfiguration = viewConfiguration, - focusManager = focusManager - ) - selectionContainerConnection?.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() = { _ -> } - } - ) + startSelectionContainerConnection() } toolbarConnection?.showToolbarMenu( rect = rect, onCopyRequested = onCopyRequested, onPasteRequested = onPasteRequested, onCutRequested = onCutRequested, - onSelectAllRequested = onSelectAllRequested + onSelectAllRequested = onSelectAllRequested, + customActions = null, ) } @@ -222,51 +199,193 @@ internal class TextInputService( toolbarConnection?.hideToolbar() stopSelectionContainerConnection() } + + private fun startSelectionContainerConnection() { + val connection = SelectionContainerConnection( + coroutineScope = coroutineScope, + viewConfiguration = viewConfiguration, + focusManager = focusManager + ).also { + it.rootView.setFrame(view.bounds) + view.addSubview(it.rootView) + } + selectionContainerConnection = 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() = { _ -> } + } + ) + } } } - 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 2c569d6a0cda2..962536b39f388 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 @@ -73,7 +73,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.unit.Constraints @@ -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( @@ -758,7 +757,7 @@ internal class ComposeSceneMediator( CompositionLocalProvider( LocalInteropContainer provides interopContainer, LocalUIView provides _overlayView, - LocalNativeTextInputContext provides textInputService.nativeTextInputContext, + LocalTextInputContainer provides textInputService.textInputContainer, content = content ) @@ -802,6 +801,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 6886739858709..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 @@ -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 @@ -33,26 +35,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 = {} @@ -62,36 +60,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) { @@ -126,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) @@ -135,7 +134,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() } @@ -167,11 +167,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( @@ -181,7 +182,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 4d3eadf20548d..2b949d9e4ab66 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 @@ -43,33 +44,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 @@ -79,21 +80,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) { @@ -139,8 +137,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 ) } @@ -154,17 +152,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 @@ -176,7 +178,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 = CURSOR_THICKNESS / 2 val center = (it.left + it.right) / 2 it.copy(left = center - halfWidth, right = center + halfWidth) @@ -207,7 +209,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, @@ -219,7 +221,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(), @@ -235,7 +237,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, @@ -248,7 +250,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, @@ -262,7 +264,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, @@ -296,7 +298,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) @@ -305,24 +307,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 cbac7ac1e7e8d..2b965acf3ec7b 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,6 +16,7 @@ package androidx.compose.ui.text.input +import androidx.compose.ui.platform.DetachedTextEditingDelegate import androidx.compose.ui.platform.SkikoUITextInputTraits import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.scene.ComposeSceneFocusManager @@ -26,17 +27,16 @@ 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 ) { private val keyboardlessInputTraits = object : SkikoUITextInputTraits { val emptyInputView = UIView(frame = CGRectZero.readValue()) 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 852701c42e4ab..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 @@ -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 @@ -39,20 +41,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?, @@ -80,16 +79,20 @@ 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) { + val inputViewWasFirstResponder = textInputView.isFirstResponder currentRequest = request currentTextFieldValue = request.stateSnapshot() + inputTraits = getUITextInputTraits(request.imeOptions) - attachInputToView() showKeyboard() - textInputView.reloadInputViews() - } - protected abstract fun attachInputToView() + if (inputViewWasFirstResponder) { + textInputView.reloadInputViews() + } + } open fun stop() { currentRequest = null @@ -97,11 +100,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) @@ -111,6 +117,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 @@ -286,7 +294,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 @@ -305,7 +313,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) @@ -337,7 +345,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() { @@ -423,36 +431,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 @@ -484,9 +464,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 097a1d93355bb..622f57741eb5f 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 @@ -78,11 +78,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) { @@ -90,11 +91,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 9ee2d97ea54ad..0c441e5c4ba1e 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 @@ -114,7 +114,7 @@ internal class NativeTextInputView( clipsToBounds = false } - override fun canBecomeFirstResponder() = true + override fun canBecomeFirstResponder() = input.isInteractive private val selectionInteraction = UITextInteraction.textInteractionForMode(UITextInteractionMode.UITextInteractionModeEditable) @@ -134,6 +134,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..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 @@ -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 + + /** + * Opaque 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 dc4776ca0d6b2..b899b146fb6e5 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 @@ -285,7 +285,7 @@ class TextFieldEditMenuTest { @Test fun testEditableCollapsedClipboardText() = - runComplexTextFieldTest { textFieldKind, newContextMenu -> + runComplexTextFieldTest { textFieldKind, _ -> UIPasteboard.generalPasteboard().string = "Paste text" setTextFieldContent( textFieldKind = textFieldKind, @@ -294,21 +294,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) { @@ -323,7 +311,7 @@ class TextFieldEditMenuTest { @Test fun testEditableCollapsedClipboardEmpty() = - runComplexTextFieldTest { textFieldKind, newContextMenu -> + runComplexTextFieldTest { textFieldKind, _ -> UIPasteboard.generalPasteboard().string = null setTextFieldContent( textFieldKind = textFieldKind, @@ -332,21 +320,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 @@ -463,8 +439,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..f58bf39fb9747 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/PasswordAutofillTest.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.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 +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.PasswordVisualTransformation +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 kotlinx.cinterop.ExperimentalForeignApi +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, ExperimentalForeignApi::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, ExperimentalForeignApi::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, + ) + BasicSecureTextField( + state = rememberTextFieldState(PASSWORD), + keyboardOptions = passwordOptions, + textObfuscationMode = TextObfuscationMode.Hidden, + ) + } else { + BasicTextField( + value = USERNAME, + onValueChange = {}, + modifier = usernameModifier, + keyboardOptions = usernameOptions, + ) + BasicTextField( + value = PASSWORD, + onValueChange = {}, + keyboardOptions = passwordOptions, + visualTransformation = PasswordVisualTransformation(), + ) + } + } + } + + 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 26a7b592ddce6..52e45856364d5 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 @@ -1100,6 +1100,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