From c0251cdb8825ec92d969f155de8d79bc320a400d Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Mon, 30 Jun 2025 14:54:43 +0400 Subject: [PATCH 01/14] Squashed the following changes: - Fixed lack of items in the context menu with NITI - Forwarded cursor / selection colors in TF1 / TF2 - Disabled Compose cursor in NITI mode in both TFs - Created expect / actual for the Compose text selection highlight in TF1, hidden it under NITI flag in uikit sourceset - Renamed UIKitTextContextMenuHandler.kt into more appropriate UIKitNativeTextInputContext - Created expect / actual for altering drawing selection rect on different platforms - fixed incorrect positioning of context menu - disabled back newContextMenu flag, rewrote showEditMenu logic in UIKitTextInputService.uikit.kt with using NITI toggle, created a signle entry point for context menu, removed some comments - context menu without NITI fixes + added existing obj-c files to the project which were forgotten to add during the merge - merge fixes after commit with refactoring InputViews.uikit.kt - Reverted pointerInput handling in TF1,2, reverted view hierarchy, hidden almost everything related to the NITI under the flag, added some TODOs with bad fixes - Added feature flag for NITI to platform ime options - added NITI tests screens to the example app - Forwarded custom actions to the IntermediateTextInputUIView.uikit.kt - Enabled native context menu, added public API for updating context menu state (foundation -> ui), extracted all UITextInput methods from objc file to separate class, temporarily disabled current context menu - Extracted UITextInput methods from CMPEditMenuView to CMPTextInputView, fixed NewContextMenuApi menu - (wip) commented CMPEditMenu methods calls after rebase with new context menu api on ios - (wip) turned on native selection rects (LTR only!) + fixed appearing of the text editing menu by tapping on the selection rects + more appropriate naming + fixed imports - (wip) disabled custom iOS tap handlers in TF1,2 - (wip) fixed native text views positioning in TF1 - (wip) disabled compose selection handles - (wip) fixed scroll positioning in BTF2 - (wip) fixed touch forwarding - (wip) fixed positioning of ScrollView and TextView issue - (wip) removed unnecessary comments - (wip) fixed sizing of TextScrollView and TextView - (wip) transfered changes from previous niti branch - (wip) rebase fixes --- .../foundation/text/CoreTextField.android.kt | 24 + .../internal/TextFieldCoreModifier.android.kt | 37 + .../foundation/ComposeFoundationFlags.kt | 1 + .../compose/foundation/text/CoreTextField.kt | 16 +- .../foundation/text/TextFieldDelegate.kt | 3 +- .../input/internal/TextFieldCoreModifier.kt | 70 +- .../selection/TextFieldSelectionState.kt | 18 + .../foundation/text/CoreTextField.jvm.kt | 24 + .../internal/TextFieldCoreModifier.jvm.kt | 37 + .../text/selection/SelectionHandles.jvm.kt | 35 + .../foundation/text/CoreTextField.macos.kt | 24 + .../internal/TextFieldCoreModifier.macos.kt | 37 + .../text/selection/SelectionHandles.macos.kt | 35 + ...CupertinoTextFieldPointerModifier.skiko.kt | 2 +- ...cyPlatformTextInputServiceAdapter.skiko.kt | 8 + .../input/internal/TextInputSession.skiko.kt | 8 + .../text/selection/SelectionHandles.skiko.kt | 2 +- .../cupertino/CupertinoOverscrollEffect.kt | 2 +- .../foundation/text/ContextMenu.uikit.kt | 21 +- .../foundation/text/CoreTextField.uikit.kt | 39 + .../internal/TextFieldCoreModifier.uikit.kt | 61 ++ .../TextFieldSelectionState.uikit.kt | 2 +- .../text/selection/SelectionHandles.uikit.kt | 43 ++ .../foundation/text/CoreTextField.web.kt | 24 + .../internal/TextFieldCoreModifier.web.kt | 37 + .../text/selection/SelectionHandles.jsWasm.kt | 40 ++ .../compose/mpp/demo/components/Components.kt | 2 + .../mpp/demo/textfield/PlatformImeHelpers.kt | 23 + .../compose/mpp/demo/textfield/TextFields.kt | 424 ++++++++++- .../textfield/PlatformImeHelpers.desktop.kt | 25 + .../demo/textfield/PlatformImeHelpers.js.kt | 25 + .../PlatformImeHelpers.macosArm64.kt | 25 + .../textfield/PlatformImeHelpers.macosX64.kt | 25 + .../textfield/PlatformImeHelpers.uikit.kt | 30 + .../textfield/PlatformImeHelpers.wasmJs.kt | 25 + .../ui/text/input/PlatformImeOptions.uikit.kt | 18 +- .../CMPUIKitUtils/CMPEditMenuCustomAction.h | 26 + .../CMPUIKitUtils/CMPEditMenuCustomAction.m | 38 + .../CMPUIKitUtils/CMPEditMenuView.h | 13 +- .../CMPUIKitUtils/CMPEditMenuView.m | 55 +- .../CMPTextInputStringTokenizer.h | 28 + .../CMPTextInputStringTokenizer.m | 37 + .../CMPUIKitUtils/CMPTextInputView.h | 29 + .../CMPUIKitUtils/CMPTextInputView.m | 128 ++++ .../CMPUIKitUtils/CMPUIKitUtils.h | 3 + .../PlatformTextInputMethodRequest.skiko.kt | 4 + .../ui/platform/IOSSkikoInput.uikit.kt | 25 + .../platform/UIKitNativeTextInputContext.kt | 36 + .../platform/UIKitTextInputService.uikit.kt | 501 +++++++++++-- .../ui/scene/ComposeSceneMediator.uikit.kt | 17 + .../ui/uikit/UIKitCompositionLocals.uikit.kt | 7 + .../compose/ui/window/InputViews.uikit.kt | 21 +- .../IntermediateTextInputUIView.uikit.kt | 676 +++++++++++++++++- 53 files changed, 2747 insertions(+), 169 deletions(-) create mode 100644 compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/CoreTextField.android.kt create mode 100644 compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.android.kt create mode 100644 compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/CoreTextField.jvm.kt create mode 100644 compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.jvm.kt create mode 100644 compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.jvm.kt create mode 100644 compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/CoreTextField.macos.kt create mode 100644 compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.macos.kt create mode 100644 compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.macos.kt create mode 100644 compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/CoreTextField.uikit.kt create mode 100644 compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.uikit.kt create mode 100644 compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.uikit.kt create mode 100644 compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/CoreTextField.web.kt create mode 100644 compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.web.kt create mode 100644 compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.jsWasm.kt create mode 100644 compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.kt create mode 100644 compose/mpp/demo/src/desktopMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.desktop.kt create mode 100644 compose/mpp/demo/src/jsMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.js.kt create mode 100644 compose/mpp/demo/src/macosArm64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosArm64.kt create mode 100644 compose/mpp/demo/src/macosX64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosX64.kt create mode 100644 compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.uikit.kt create mode 100644 compose/mpp/demo/src/wasmJsMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.wasmJs.kt create mode 100644 compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuCustomAction.h create mode 100644 compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuCustomAction.m create mode 100644 compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputStringTokenizer.h create mode 100644 compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputStringTokenizer.m create mode 100644 compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputView.h create mode 100644 compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputView.m create mode 100644 compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitNativeTextInputContext.kt diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/CoreTextField.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/CoreTextField.android.kt new file mode 100644 index 0000000000000..52a729912df5c --- /dev/null +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/CoreTextField.android.kt @@ -0,0 +1,24 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +@Composable +internal actual fun platformShouldDrawTextControls(cursorBrush: Brush, selectionColor: Color): Boolean = false diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.android.kt new file mode 100644 index 0000000000000..2dc0caef035fb --- /dev/null +++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.android.kt @@ -0,0 +1,37 @@ +/* + * 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.input.internal + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange + +internal actual fun CompositionLocalConsumerModifierNode.drawPlatformSelection( + scope: DrawScope, + selection: TextRange, + textLayoutResult: TextLayoutResult +) = drawDefaultSelection(scope, selection, textLayoutResult) + +internal actual fun CompositionLocalConsumerModifierNode.drawPlatformCursor( + scope: DrawScope, + cursorRect: Rect, + brush: Brush, + alpha: Float +) = drawDefaultCursor(scope, cursorRect, brush, alpha) \ No newline at end of file diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt index f28b1f4c995a7..b93c2d2cbd61a 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt @@ -68,6 +68,7 @@ object ComposeFoundationFlags { * [BasicTextField][androidx.compose.foundation.text.BasicTextField]s. If false, the previous * context menu that has no public APIs will be used instead. */ + // TODO mazunin-v: don't forget to revert it @Suppress("MutableBareField") @JvmField var isNewContextMenuEnabled = false /** diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt index fb5e021fdd55c..77fb1b4b6bfbb 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt @@ -387,6 +387,7 @@ internal fun CoreTextField( manager, enabled, interactionSource, state, focusRequester, readOnly, offsetMapping ) + val platformDrawsTextControls = platformShouldDrawTextControls(cursorBrush, state.selectionBackgroundColor) val drawModifier = Modifier.drawBehind { state.layoutResult?.let { layoutResult -> @@ -400,6 +401,7 @@ internal fun CoreTextField( layoutResult.value, state.highlightPaint, state.selectionBackgroundColor, + !platformDrawsTextControls ) } } @@ -456,7 +458,7 @@ internal fun CoreTextField( focusRequester, ) - val showCursor = enabled && !readOnly && windowInfo.isWindowFocused && !state.hasHighlight() + val showCursor = enabled && !readOnly && windowInfo.isWindowFocused && !state.hasHighlight() && !platformDrawsTextControls val cursorModifier = Modifier.cursor(state, value, offsetMapping, cursorBrush, showCursor) DisposableEffect(manager) { onDispose { manager.hideSelectionToolbar() } } @@ -657,7 +659,7 @@ internal fun CoreTextField( if ( state.handleState == HandleState.Cursor && !readOnly && showHandleAndMagnifier ) { - TextFieldCursorHandle(manager = manager) +// TextFieldCursorHandle(manager = manager) } } } @@ -1108,6 +1110,16 @@ internal expect fun CursorHandle( minTouchTargetSize: DpSize = DpSize.Unspecified, ) +/** + * Determines whether the platform should handle drawing text controls, such as cursor and selection highlights. + * + * @param cursorBrush A brush used to draw the cursor in the text field. + * @param selectionColor The color used to highlight the selected text. + * @return A boolean value indicating whether the platform should handle drawing text controls. + */ +@Composable +internal expect fun platformShouldDrawTextControls(cursorBrush: Brush, selectionColor: Color): Boolean + // TODO(b/262648050) Try to find a better API. private fun notifyFocusedRect( state: LegacyTextFieldState, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt index 3f0d4c54891db..6315439881d8c 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt @@ -135,6 +135,7 @@ internal class TextFieldDelegate { textLayoutResult: TextLayoutResult, highlightPaint: Paint, selectionBackgroundColor: Color, + drawSelectionHighlight: Boolean = true, ) { if (!selectionPreviewHighlightRange.collapsed) { highlightPaint.color = selectionBackgroundColor @@ -157,7 +158,7 @@ internal class TextFieldDelegate { textLayoutResult, highlightPaint, ) - } else if (!value.selection.collapsed) { + } else if (!value.selection.collapsed && drawSelectionHighlight) { highlightPaint.color = selectionBackgroundColor drawHighlight( canvas, diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.kt index 5074d460d7f11..d5daee2fbcc2e 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.kt @@ -518,9 +518,7 @@ internal class TextFieldCoreModifierNode( val start = selection.min val end = selection.max if (start != end) { - val selectionBackgroundColor = currentValueOf(LocalTextSelectionColors).backgroundColor - val selectionPath = textLayoutResult.getPathForRange(start, end) - drawPath(selectionPath, color = selectionBackgroundColor) + this@TextFieldCoreModifierNode.drawPlatformSelection(this, selection, textLayoutResult) } } @@ -571,12 +569,13 @@ internal class TextFieldCoreModifierNode( val cursorRect = textFieldSelectionState.getCursorRect() - drawLine( - cursorBrush, - cursorRect.topCenter, - cursorRect.bottomCenter, + // Delegate the actual drawing to platform-specific implementation, passing only + // prepared parameters to avoid exposing private members outside this node. + this@TextFieldCoreModifierNode.drawPlatformCursor( + scope = this, + cursorRect = cursorRect, + brush = cursorBrush, alpha = cursorAlphaValue, - strokeWidth = cursorRect.width, ) } @@ -681,3 +680,58 @@ private fun Float.roundToNext(): Float = this > 0 -> ceil(this) else -> floor(this) } + +/** + * Draws the visual highlight for the given text [selection]. + * + * Platforms may override this to customize how text selection is rendered. The shared default + * implementation is provided by `drawDefaultSelection`. + * + * @param scope [DrawScope] used for issuing drawing commands. + * @param selection Range of selected text in [textLayoutResult]. + * @param textLayoutResult Layout information used to map [selection] to canvas coordinates. + */ +internal expect fun CompositionLocalConsumerModifierNode.drawPlatformSelection(scope: DrawScope, selection: TextRange, textLayoutResult: TextLayoutResult) + +internal fun CompositionLocalConsumerModifierNode.drawDefaultSelection(scope: DrawScope, selection: TextRange, textLayoutResult: TextLayoutResult) { + val selectionBackgroundColor = currentValueOf(LocalTextSelectionColors).backgroundColor + val selectionPath = textLayoutResult.getPathForRange(selection.min, selection.max) + with(scope) { + drawPath(selectionPath, color = selectionBackgroundColor) + } +} + +/** + * Draws the visual cursor indicator using the provided [cursorRect]. + * + * Platforms may override this to customize how the text cursor is rendered. The shared default + * implementation is provided by `drawDefaultCursor`. + * + * @param scope [DrawScope] used for issuing drawing commands. + * @param cursorRect Rectangle representing the cursor in canvas coordinates. + * @param brush [Brush] used to paint the cursor. + * @param alpha Opacity to use when drawing the cursor. + */ +internal expect fun CompositionLocalConsumerModifierNode.drawPlatformCursor( + scope: DrawScope, + cursorRect: Rect, + brush: Brush, + alpha: Float, +) + +internal fun CompositionLocalConsumerModifierNode.drawDefaultCursor( + scope: DrawScope, + cursorRect: Rect, + brush: Brush, + alpha: Float, +) { + with(scope) { + drawLine( + brush = brush, + start = cursorRect.topCenter, + end = cursorRect.bottomCenter, + alpha = alpha, + strokeWidth = cursorRect.width, + ) + } +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt index d63dea197046a..83f4259671610 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt @@ -536,6 +536,7 @@ internal class TextFieldSelectionState( try { coroutineScope { launch { observeTextChanges() } + launch { observeSelectionChanges() } launch { observeTextToolbarVisibility() } } } finally { @@ -546,6 +547,23 @@ internal class TextFieldSelectionState( } } + private suspend fun observeSelectionChanges() { + snapshotFlow { + val isCollapsed = textFieldState.visualText.selection.collapsed + if (draggingHandle == null && isInTouchMode) { + if (isCollapsed) { + Cursor + } else { + Selection + } + } else { + None + } + }.collect { state -> + updateTextToolbarState(state) + } + } + fun updateTextToolbarState(textToolbarState: TextToolbarState) { this.textToolbarState = textToolbarState } diff --git a/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/CoreTextField.jvm.kt b/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/CoreTextField.jvm.kt new file mode 100644 index 0000000000000..52a729912df5c --- /dev/null +++ b/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/CoreTextField.jvm.kt @@ -0,0 +1,24 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +@Composable +internal actual fun platformShouldDrawTextControls(cursorBrush: Brush, selectionColor: Color): Boolean = false diff --git a/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.jvm.kt b/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.jvm.kt new file mode 100644 index 0000000000000..2dc0caef035fb --- /dev/null +++ b/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.jvm.kt @@ -0,0 +1,37 @@ +/* + * 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.input.internal + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange + +internal actual fun CompositionLocalConsumerModifierNode.drawPlatformSelection( + scope: DrawScope, + selection: TextRange, + textLayoutResult: TextLayoutResult +) = drawDefaultSelection(scope, selection, textLayoutResult) + +internal actual fun CompositionLocalConsumerModifierNode.drawPlatformCursor( + scope: DrawScope, + cursorRect: Rect, + brush: Brush, + alpha: Float +) = drawDefaultCursor(scope, cursorRect, brush, alpha) \ No newline at end of file diff --git a/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.jvm.kt b/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.jvm.kt new file mode 100644 index 0000000000000..dd73bb52c38b6 --- /dev/null +++ b/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.jvm.kt @@ -0,0 +1,35 @@ +/* + * 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.selection + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.ResolvedTextDirection +import androidx.compose.ui.unit.DpSize + +@Composable +internal actual fun SelectionHandle( + offsetProvider: OffsetProvider, + isStartHandle: Boolean, + direction: ResolvedTextDirection, + handlesCrossed: Boolean, + minTouchTargetSize: DpSize, + lineHeight: Float, + modifier: Modifier +) { + // TODO: check that JVM target shouldn't require selection handles +} \ 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 new file mode 100644 index 0000000000000..52a729912df5c --- /dev/null +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/CoreTextField.macos.kt @@ -0,0 +1,24 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +@Composable +internal actual fun platformShouldDrawTextControls(cursorBrush: Brush, selectionColor: Color): Boolean = false diff --git a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.macos.kt b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.macos.kt new file mode 100644 index 0000000000000..2dc0caef035fb --- /dev/null +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.macos.kt @@ -0,0 +1,37 @@ +/* + * 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.input.internal + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange + +internal actual fun CompositionLocalConsumerModifierNode.drawPlatformSelection( + scope: DrawScope, + selection: TextRange, + textLayoutResult: TextLayoutResult +) = drawDefaultSelection(scope, selection, textLayoutResult) + +internal actual fun CompositionLocalConsumerModifierNode.drawPlatformCursor( + scope: DrawScope, + cursorRect: Rect, + brush: Brush, + alpha: Float +) = drawDefaultCursor(scope, cursorRect, brush, alpha) \ No newline at end of file diff --git a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.macos.kt b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.macos.kt new file mode 100644 index 0000000000000..79d89e756c3f1 --- /dev/null +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.macos.kt @@ -0,0 +1,35 @@ +/* + * 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.selection + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.ResolvedTextDirection +import androidx.compose.ui.unit.DpSize + +@Composable +internal actual fun SelectionHandle( + offsetProvider: OffsetProvider, + isStartHandle: Boolean, + direction: ResolvedTextDirection, + handlesCrossed: Boolean, + minTouchTargetSize: DpSize, + lineHeight: Float, + modifier: Modifier +) { + // MacOS doesn't require selection handles at all +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt index 90ef1744f6347..5ea58151d40e3 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt @@ -343,4 +343,4 @@ private fun createTextFieldValue( annotatedString = annotatedString, selection = selection ) -} +} \ 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 3269e42bf5362..47539318a0957 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 @@ -20,6 +20,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.text.TextLayoutResult @@ -35,6 +36,7 @@ import androidx.compose.ui.text.input.SetComposingTextCommand 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.TextInputService import kotlinx.coroutines.Job @OptIn(ExperimentalComposeUiApi::class) @@ -48,6 +50,7 @@ internal actual fun createLegacyPlatformTextInputServiceAdapter(): private var focusedRectInRoot by mutableStateOf(Rect.Zero) private var textFieldRectInRoot by mutableStateOf(Rect.Zero) private var textClippingRectInRoot by mutableStateOf(Rect.Zero) + private var textUnclippingOffsetInRoot by mutableStateOf(Offset.Zero) override fun startInput( value: TextFieldValue, @@ -95,6 +98,10 @@ internal actual fun createLegacyPlatformTextInputServiceAdapter(): textClippingRectInRoot = matrix.map(innerTextFieldBounds) val cursorOffset = offsetMapping.originalToTransformed(textFieldValue.selection.max) focusedRectInRoot = matrix.map(textLayoutResult.getCursorRect(cursorOffset)) + textUnclippingOffsetInRoot = Offset( + x = textClippingRectInRoot.topLeft.x - innerTextFieldBounds.topLeft.x, + y = textClippingRectInRoot.topLeft.y - innerTextFieldBounds.topLeft.y + ) } override fun startStylusHandwriting() {} @@ -164,6 +171,7 @@ internal actual fun createLegacyPlatformTextInputServiceAdapter(): focusedRectInRoot = { focusedRectInRoot }, textFieldRectInRoot = { textFieldRectInRoot }, textClippingRectInRoot = { textClippingRectInRoot }, + textUnclippingOffsetInRoot = { textUnclippingOffsetInRoot }, editText = editBlock ) } 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 f5b7d3807a2ca..67d38bcb4fa1d 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 @@ -25,7 +25,11 @@ import androidx.compose.foundation.text.offsetByCodePoints import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.PlatformTextInputSession import androidx.compose.ui.platform.ViewConfiguration @@ -100,6 +104,8 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe fun textClippingRectInRoot() = layoutState.coreNodeCoordinates?.boundsInRoot() + fun textUnclippingOffsetInRoot() = layoutState.textLayoutNodeCoordinates?.positionInRoot() + startInputMethod( SkikoPlatformTextInputMethodRequest( value = { state.untransformedText.toTextFieldValue() }, @@ -111,6 +117,7 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe focusedRectInRoot = ::focusedRectInRoot, textFieldRectInRoot = ::textFieldRectInRoot, textClippingRectInRoot = ::textClippingRectInRoot, + textUnclippingOffsetInRoot = ::textUnclippingOffsetInRoot, editText = ::editText ) ) @@ -235,5 +242,6 @@ internal data class SkikoPlatformTextInputMethodRequest( override val focusedRectInRoot: () -> Rect?, override val textFieldRectInRoot: () -> Rect?, override val textClippingRectInRoot: () -> Rect?, + override val textUnclippingOffsetInRoot: () -> Offset?, override val editText: (block: TextEditingScope.() -> Unit) -> Unit ): PlatformTextInputMethodRequest diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.skiko.kt index 1cb0d1e1af38b..4477f5ae99b45 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.skiko.kt @@ -63,7 +63,7 @@ private val THICKNESS = 2.dp * [SelectionHandle] was initially designed as iOS entity but later was commonized as is. */ @Composable -internal actual fun SelectionHandle( +internal fun SkikoSelectionHandle( offsetProvider: OffsetProvider, isStartHandle: Boolean, direction: ResolvedTextDirection, diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt index 497f47a8544d7..1988186b0bcf0 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt @@ -480,7 +480,7 @@ private class CupertinoOverscrollNode( pointersDown-- } } - assert(pointersDown >= 0) { "pointersDown cannot be negative" } +// assert(pointersDown >= 0) { "pointersDown cannot be negative" } // TODO Overscroll in NITI shouldn't be fixed like that } } diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/ContextMenu.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/ContextMenu.uikit.kt index 915fb586ae84c..05cccf31727a3 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/ContextMenu.uikit.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/ContextMenu.uikit.kt @@ -48,7 +48,9 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.UIKitNativeTextInputContext 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 @@ -145,6 +147,8 @@ private fun ProvideNewContextMenuDefaultProviders( ) { val toolbarProvider = LocalTextContextMenuToolbarProvider.current val dropdownProvider = LocalTextContextMenuDropdownProvider.current + val contextMenuHandlerProvider = LocalNativeTextInputContext.current + if (toolbarProvider == null || dropdownProvider == null) { val layoutCoordinates: MutableState = remember { mutableStateOf(null, neverEqualPolicy()) @@ -160,6 +164,7 @@ private fun ProvideNewContextMenuDefaultProviders( menuDelay = menuDelay, editMenuView = editMenuView, density = density, + nativeContextMenuHandler = contextMenuHandlerProvider, coordinates = { layoutCoordinates.value } ) } @@ -197,6 +202,7 @@ private class ContextMenuToolbarProvider( private val menuDelay: Duration, val editMenuView: CMPEditMenuView, private val density: Density, + private val nativeContextMenuHandler: UIKitNativeTextInputContext, private val coordinates: () -> LayoutCoordinates? ): TextContextMenuProvider { @OptIn(FlowPreview::class) @@ -265,8 +271,17 @@ private class ContextMenuToolbarProvider( rect = rect ) }.filterNotNull().collect { - getEditMenuView().showEditMenuAtRect( - targetRect = it.rect.toCGRect(density), +// getEditMenuView().showEditMenuAtRect( +// targetRect = it.rect.toCGRect(density), +// copy = it.copy, +// cut = it.cut, +// paste = it.paste, +// selectAll = it.selectAll, +// customActions = it.customActions +// ) + + nativeContextMenuHandler.updateEditMenuState( + targetRect = it.rect, copy = it.copy, cut = it.cut, paste = it.paste, @@ -279,7 +294,7 @@ private class ContextMenuToolbarProvider( suspendCancellableCoroutine { continuation -> session = TextContextMenuSessionImpl(editMenuView, continuation) continuation.invokeOnCancellation { - editMenuView.hideEditMenu() + editMenuView.hideEditMenu() } } job.cancel() diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/CoreTextField.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/CoreTextField.uikit.kt new file mode 100644 index 0000000000000..54cba3360fdc6 --- /dev/null +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/CoreTextField.uikit.kt @@ -0,0 +1,39 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.uikit.LocalNativeTextInputContext + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal actual fun platformShouldDrawTextControls(cursorBrush: Brush, selectionColor: Color): Boolean { + val nativeInputContext = LocalNativeTextInputContext.current + val isUsingNativeInput = nativeInputContext.usingNativeInput() + if (isUsingNativeInput) { + val controlsColor = (cursorBrush as? SolidColor) + ?.value + ?.takeIf { it != Color.Unspecified } + ?: selectionColor + nativeInputContext.updateTintColor(controlsColor) + } + return isUsingNativeInput +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.uikit.kt new file mode 100644 index 0000000000000..72c6a82bcccae --- /dev/null +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.uikit.kt @@ -0,0 +1,61 @@ +/* + * 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.input.internal + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.uikit.LocalNativeTextInputContext + +@OptIn(ExperimentalComposeUiApi::class) +internal actual fun CompositionLocalConsumerModifierNode.drawPlatformSelection( + scope: DrawScope, + selection: TextRange, + textLayoutResult: TextLayoutResult +) { + val usingNITI = currentValueOf(LocalNativeTextInputContext).usingNativeInput() + // Don't draw selection on iOS when using NITI + if (!usingNITI) { + drawDefaultSelection(scope, selection, textLayoutResult) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +internal actual fun CompositionLocalConsumerModifierNode.drawPlatformCursor( + scope: DrawScope, + cursorRect: Rect, + brush: Brush, + alpha: Float +) { + val nativeTextInputContext = currentValueOf(LocalNativeTextInputContext) + // Don't draw selection on iOS when using NITI + if (!nativeTextInputContext.usingNativeInput()) { + drawDefaultCursor(scope, cursorRect, brush, alpha) + } else { + (brush as? SolidColor) + ?.value + ?.takeIf { it != Color.Unspecified } + ?.let { nativeTextInputContext.updateTintColor(it) } + } +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt index 91ad3a3ae55e6..98517fb1fc8db 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt @@ -418,4 +418,4 @@ internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( textFieldItem(TextContextMenuKeys.SelectAllKey, enabled = canShowSelectAllMenuItem()) { selectAll() } separator() } -} +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.uikit.kt new file mode 100644 index 0000000000000..00a3d242e5dab --- /dev/null +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.uikit.kt @@ -0,0 +1,43 @@ +/* + * 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.selection + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.ResolvedTextDirection +import androidx.compose.ui.uikit.LocalNativeTextInputContext +import androidx.compose.ui.unit.DpSize + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal actual fun SelectionHandle( + offsetProvider: OffsetProvider, + isStartHandle: Boolean, + direction: ResolvedTextDirection, + handlesCrossed: Boolean, + minTouchTargetSize: DpSize, + lineHeight: Float, + modifier: Modifier +) { + val nativeInputProvider = LocalNativeTextInputContext.current + if (nativeInputProvider.usingNativeInput()) { + return // iOS draws selection handles itself. + } else { + return SkikoSelectionHandle(offsetProvider, isStartHandle, direction, handlesCrossed, minTouchTargetSize, lineHeight, modifier) + } +} \ 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 new file mode 100644 index 0000000000000..52a729912df5c --- /dev/null +++ b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/CoreTextField.web.kt @@ -0,0 +1,24 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +@Composable +internal actual fun platformShouldDrawTextControls(cursorBrush: Brush, selectionColor: Color): Boolean = false diff --git a/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.web.kt b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.web.kt new file mode 100644 index 0000000000000..2dc0caef035fb --- /dev/null +++ b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.web.kt @@ -0,0 +1,37 @@ +/* + * 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.input.internal + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange + +internal actual fun CompositionLocalConsumerModifierNode.drawPlatformSelection( + scope: DrawScope, + selection: TextRange, + textLayoutResult: TextLayoutResult +) = drawDefaultSelection(scope, selection, textLayoutResult) + +internal actual fun CompositionLocalConsumerModifierNode.drawPlatformCursor( + scope: DrawScope, + cursorRect: Rect, + brush: Brush, + alpha: Float +) = drawDefaultCursor(scope, cursorRect, brush, alpha) \ No newline at end of file diff --git a/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.jsWasm.kt b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.jsWasm.kt new file mode 100644 index 0000000000000..e08ca490bb64b --- /dev/null +++ b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/selection/SelectionHandles.jsWasm.kt @@ -0,0 +1,40 @@ +/* + * 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.selection + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.ResolvedTextDirection +import androidx.compose.ui.unit.DpSize + +@Composable +internal actual fun SelectionHandle( + offsetProvider: OffsetProvider, + isStartHandle: Boolean, + direction: ResolvedTextDirection, + handlesCrossed: Boolean, + minTouchTargetSize: DpSize, + lineHeight: Float, + modifier: Modifier +) = SkikoSelectionHandle( +offsetProvider = offsetProvider, +isStartHandle = isStartHandle, +direction = direction, +handlesCrossed = handlesCrossed, +minTouchTargetSize = minTouchTargetSize, +lineHeight = lineHeight, +modifier = modifier) \ No newline at end of file diff --git a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/components/Components.kt b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/components/Components.kt index f324c9ab6a0e4..a989057ec7e15 100644 --- a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/components/Components.kt +++ b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/components/Components.kt @@ -31,6 +31,7 @@ import androidx.compose.mpp.demo.components.material3.SearchBarExample import androidx.compose.mpp.demo.components.material3.WindowSizeClassExample import androidx.compose.mpp.demo.components.popup.Popups import androidx.compose.mpp.demo.components.text.TextDemos +import androidx.compose.mpp.demo.textfield.NITITextFields import androidx.compose.mpp.demo.textfield.TextFields private val MaterialComponents = Screen.Selection( @@ -58,6 +59,7 @@ val Components = Screen.Selection( Dialogs, TextDemos, TextFields, + NITITextFields, LazyLayouts, MaterialComponents, Material3Components, diff --git a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.kt b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.kt new file mode 100644 index 0000000000000..773816d2cb329 --- /dev/null +++ b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.kt @@ -0,0 +1,23 @@ +/* + * 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.mpp.demo.textfield + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.ExperimentalComposeUiApi + +@ExperimentalComposeUiApi +expect fun nativeKeyboardOptionsUseNativeInputHandling(enabled: Boolean): KeyboardOptions diff --git a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/TextFields.kt b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/TextFields.kt index f045d62fa2245..83c84a94cb8af 100644 --- a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/TextFields.kt +++ b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/textfield/TextFields.kt @@ -16,29 +16,61 @@ package androidx.compose.mpp.demo.textfield +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer 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.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.TextFieldState +import androidx.compose.foundation.text.input.delete +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon import androidx.compose.material.OutlinedTextField +import androidx.compose.material.SecureTextField import androidx.compose.material.Text import androidx.compose.material.TextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.mpp.demo.Screen import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp val TextFields = Screen.Selection( "TextFields", @@ -110,9 +142,12 @@ val TextFields = Screen.Selection( defaultModifier.height(24.dp) ) Box(Modifier.height(16.dp)) + @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) + val nativeKeyboardOptions = nativeKeyboardOptionsUseNativeInputHandling(true) BasicTextField( textFieldState2, - defaultModifier.height(24.dp) + defaultModifier.height(24.dp), + keyboardOptions = nativeKeyboardOptions ) Box(Modifier.height(16.dp)) BasicTextField( @@ -128,6 +163,389 @@ val TextFields = Screen.Selection( } ) + +val NITITextFields = Screen.Selection( + title = "NITI Tests", + screens = listOf( + Screen.Example("Brush") { + ClearFocusBox { + Column { + var text by remember { mutableStateOf("BasicTextField 1 with a long text") } + TextField( + value = text, + onValueChange = { text = it }, + Modifier.fillMaxWidth().height(56.dp), + textStyle = TextStyle( + brush = Brush.linearGradient(listOf(Color.Magenta, Color.Cyan)), + fontSize = 18.sp + ) + ) + Box(modifier = Modifier.height(16.dp)) + var text2 = rememberTextFieldState("BasicTextField 2 with a long text") + BasicTextField( + state = text2, + Modifier.fillMaxWidth().height(56.dp), + textStyle = TextStyle( + brush = Brush.linearGradient(listOf(Color.Magenta, Color.Cyan)), + fontSize = 18.sp + ) + ) + } + } + }, + Screen.Example("InteractionSource focus border") { + Column { + var text by remember { mutableStateOf("BasicTextField 1 with a long text") } + val interaction1 = remember { MutableInteractionSource() } + val focused1 by interaction1.collectIsFocusedAsState() + BasicTextField( + value = text, + onValueChange = { text = it }, + interactionSource = interaction1, + modifier = Modifier + .border(2.dp, if (focused1) Color.Cyan else Color.Gray, RoundedCornerShape(8.dp)) + .padding(8.dp) + ) + Box(modifier = Modifier.height(16.dp)) + val state = rememberTextFieldState("BasicTextField 2 with a long text") + val interaction = remember { MutableInteractionSource() } + val focused by interaction.collectIsFocusedAsState() + BasicTextField( + state = state, + interactionSource = interaction, + modifier = Modifier + .border(2.dp, if (focused) Color.Cyan else Color.Gray, RoundedCornerShape(8.dp)) + .padding(8.dp) + ) + } + }, + Screen.Example("TextFieldDecorator / DecorationBox") { + ClearFocusBox { + Column { + var text by remember { mutableStateOf("BasicTextField 1 with a long text") } + BasicTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth().height(56.dp), + textStyle = TextStyle(color = Color.White, fontSize = 16.sp), + decorationBox = { inner -> + Row( + Modifier + .background(Color(0xFF121212), RoundedCornerShape(10.dp)) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) + Spacer(Modifier.width(8.dp)) + Box(Modifier.weight(1f)) { + if (text.isEmpty()) Text("Search…", color = Color.Gray) + inner() + } + Text("${text.length}", color = Color.Gray) + } + } + ) + + Box(Modifier.height(16.dp)) + + val state2 = rememberTextFieldState("BasicTextField 2 with a long text") + BasicTextField( + state = state2, + modifier = Modifier.fillMaxWidth().height(56.dp), + textStyle = TextStyle(color = Color.White, fontSize = 16.sp), + decorator = { inner -> + Row( + Modifier + .background(Color(0xFF121212), RoundedCornerShape(10.dp)) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) + Spacer(Modifier.width(8.dp)) + Box(Modifier.weight(1f)) { + if (state2.text.isEmpty()) Text("Search…", color = Color.Gray) + inner() + } + Text("${state2.text.length}", color = Color.Gray) + } + } + ) + } + } + }, + Screen.Example("ScrollState") { + ClearFocusBox { + Column { + var text by remember { mutableStateOf(("lots of text BTF1 \n").repeat(40)) } + val scroll1 = rememberScrollState() + BasicTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .verticalScroll(scroll1), + textStyle = TextStyle(color = Color.Black, fontSize = 14.sp) + ) + + Box(Modifier.height(16.dp)) + + val state2 = rememberTextFieldState(("Lots of text BTF2\n").repeat(40)) + val scroll2 = rememberScrollState() + BasicTextField( + state = state2, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .verticalScroll(scroll2), + textStyle = TextStyle(color = Color.Black, fontSize = 14.sp) + ) + } + } + }, + Screen.Example("GraphicsLayer") { + ClearFocusBox { + Column { + var text by remember { mutableStateOf("BasicTextField 1 with a long text") } + val pulse1 by rememberInfiniteTransition().animateFloat( + initialValue = 0.96f, targetValue = 1.04f, + animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse) + ) + BasicTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .graphicsLayer { scaleX = pulse1; scaleY = pulse1 }, + textStyle = TextStyle(color = Color.Black, fontSize = 18.sp) + ) + + Box(Modifier.height(16.dp)) + + val state2 = rememberTextFieldState("BasicTextField 2 with a long text") + val pulse2 by rememberInfiniteTransition().animateFloat( + 0.96f, 1.04f, infiniteRepeatable(tween(600), RepeatMode.Reverse) + ) + BasicTextField( + state = state2, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .graphicsLayer { scaleX = pulse2; scaleY = pulse2 }, + textStyle = TextStyle(color = Color.Black, fontSize = 18.sp) + ) + } + } + }, + Screen.Example("Appearance modifiers") { + ClearFocusBox { + Column { + var text by remember { mutableStateOf("BasicTextField 1 with a long text") } + BasicTextField( + value = text, + onValueChange = { text = it }, + textStyle = TextStyle(color = Color.Black, fontSize = 16.sp), + cursorBrush = SolidColor(Color.Cyan), + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .background(Color(0xFF202124), RoundedCornerShape(10.dp)) + .border(1.dp, Color(0xFF2A2A2A), RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + + Box(Modifier.height(16.dp)) + + val state2 = rememberTextFieldState("BasicTextField 2 with a long text") + BasicTextField( + state = state2, + textStyle = TextStyle(color = Color.Black, fontSize = 16.sp), + cursorBrush = SolidColor(Color.Cyan), + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .background(Color(0xFF202124), RoundedCornerShape(10.dp)) + .border(1.dp, Color(0xFF2A2A2A), RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + }, + Screen.Example("Secure input") { + ClearFocusBox { + Column { + var text by remember { mutableStateOf("BasicTextField 1 with a long text") } + BasicTextField( + value = text, + onValueChange = { text = it }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false + ), + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth().height(56.dp), + textStyle = TextStyle(color = Color.Black, fontSize = 16.sp) + ) + + Box(Modifier.height(16.dp)) + + val state2 = rememberTextFieldState("SecureTextField with a long text") + SecureTextField( + state = state2, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false + ), + modifier = Modifier.fillMaxWidth().height(56.dp), + textStyle = TextStyle(color = Color.Black, fontSize = 16.sp) + ) + } + } + }, + Screen.Example("Transformations (Phone)") { + ClearFocusBox { + Column { + // VisualTransformation for String-based BasicTextField + val phoneMask = remember { + VisualTransformation { text -> + val raw = text.text.filter(Char::isDigit) + .let { if (it.startsWith("7")) it.drop(1) else it } + + val formatted = buildString { + append("+7 ") + if (raw.isNotEmpty()) { + if (raw.length >= 3) { + append("(${raw.take(3)}) ") + if (raw.length >= 6) { + append(raw.substring(3, 6)) + append('-') + if (raw.length >= 8) { + append(raw.substring(6, 8)) + append('-') + if (raw.length > 8) { + append(raw.substring(8)) + } + } else if (raw.length > 6) { + append(raw.substring(6)) + } + } else if (raw.length > 3) { + append(raw.substring(3)) + } + } else { + append(raw) + } + } + } + + // Create offset mapping for proper cursor positioning + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= 0) return 3 // "+7 " + + val digitsBeforeCursor = text.text.take(offset).count(Char::isDigit) + val normalized = if (text.text.take(offset).startsWith("7")) + (digitsBeforeCursor - 1).coerceAtLeast(0) else digitsBeforeCursor + + return when { + normalized <= 0 -> 3 + normalized <= 3 -> 4 + normalized // "+7 (xxx" + normalized <= 6 -> 6 + normalized // "+7 (xxx) xxx" + normalized <= 8 -> 7 + normalized // "+7 (xxx) xxx-xx" + else -> 8 + normalized // "+7 (xxx) xxx-xx-xx" + } + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 3) return 0 // before/in "+7 " + + val withoutPrefix = offset - 3 + val digitsCount = when { + withoutPrefix <= 1 -> 0 // "(" + withoutPrefix <= 4 -> withoutPrefix - 1 // "(xxx" + withoutPrefix <= 6 -> withoutPrefix - 2 // "(xxx) " + withoutPrefix <= 9 -> withoutPrefix - 3 // "(xxx) xxx" + withoutPrefix <= 10 -> withoutPrefix - 4 // "(xxx) xxx-" + withoutPrefix <= 12 -> withoutPrefix - 5 // "(xxx) xxx-xx" + withoutPrefix <= 13 -> withoutPrefix - 6 // "(xxx) xxx-xx-" + else -> withoutPrefix - 7 // "(xxx) xxx-xx-xxx..." + } + + return text.text.take(text.text.length) + .withIndex() + .count { it.value.isDigit() && it.index < digitsCount } + } + } + + TransformedText(AnnotatedString(formatted), offsetMapping) + } + } + + var text by remember { mutableStateOf("") } + BasicTextField( + value = text, + onValueChange = { text = it.filter { char -> char.isDigit() }.take(11) }, + visualTransformation = phoneMask, + modifier = Modifier.fillMaxWidth().height(56.dp), + textStyle = TextStyle(color = Color.Black, fontSize = 16.sp) + ) + + Box(Modifier.height(16.dp)) + + // OutputTransformation for TextFieldState-based BasicTextField + val state2 = rememberTextFieldState("") + BasicTextField( + state = state2, + inputTransformation = { + // Filter to keep only digits, max 11 digits + if (!asCharSequence().all { it.isDigit() }) { + val digitsOnly = asCharSequence().filter { it.isDigit() }.toString() + replace(0, length, digitsOnly.take(11)) + } else if (length > 11) { + delete(11, length) + } + }, + outputTransformation = { + val raw = asCharSequence().toString() + .let { if (it.startsWith("7")) it.drop(1) else it } + + val formatted = buildString { + append("+7 ") + if (raw.isNotEmpty()) { + if (raw.length >= 3) { + append("(${raw.take(3)}) ") + if (raw.length >= 6) { + append(raw.substring(3, 6)) + append('-') + if (raw.length >= 8) { + append(raw.substring(6, 8)) + append('-') + if (raw.length > 8) { + append(raw.substring(8)) + } + } else if (raw.length > 6) { + append(raw.substring(6)) + } + } else if (raw.length > 3) { + append(raw.substring(3)) + } + } else { + append(raw) + } + } + } + + replace(0, length, formatted) + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + textStyle = TextStyle(color = Color.Black, fontSize = 16.sp) + ) + } + } + }, + ) +) + @Composable private fun AlmostFullscreen() { val textState = remember { @@ -145,6 +563,7 @@ private fun AlmostFullscreen() { ) } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun AlmostFullscreen2() { val state = remember { @@ -158,7 +577,8 @@ private fun AlmostFullscreen2() { } TextField( state, - Modifier.fillMaxSize().padding(vertical = 40.dp).background(Color.LightGray) + Modifier.fillMaxSize().padding(vertical = 40.dp).background(Color.LightGray), + keyboardOptions = nativeKeyboardOptionsUseNativeInputHandling(true) ) } diff --git a/compose/mpp/demo/src/desktopMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.desktop.kt b/compose/mpp/demo/src/desktopMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.desktop.kt new file mode 100644 index 0000000000000..e704dd4e448f1 --- /dev/null +++ b/compose/mpp/demo/src/desktopMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.desktop.kt @@ -0,0 +1,25 @@ +/* + * 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.mpp.demo.textfield + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.ExperimentalComposeUiApi + +@ExperimentalComposeUiApi +actual fun nativeKeyboardOptionsUseNativeInputHandling(enabled: Boolean): KeyboardOptions { + return KeyboardOptions() +} diff --git a/compose/mpp/demo/src/jsMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.js.kt b/compose/mpp/demo/src/jsMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.js.kt new file mode 100644 index 0000000000000..e704dd4e448f1 --- /dev/null +++ b/compose/mpp/demo/src/jsMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.js.kt @@ -0,0 +1,25 @@ +/* + * 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.mpp.demo.textfield + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.ExperimentalComposeUiApi + +@ExperimentalComposeUiApi +actual fun nativeKeyboardOptionsUseNativeInputHandling(enabled: Boolean): KeyboardOptions { + return KeyboardOptions() +} diff --git a/compose/mpp/demo/src/macosArm64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosArm64.kt b/compose/mpp/demo/src/macosArm64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosArm64.kt new file mode 100644 index 0000000000000..e704dd4e448f1 --- /dev/null +++ b/compose/mpp/demo/src/macosArm64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosArm64.kt @@ -0,0 +1,25 @@ +/* + * 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.mpp.demo.textfield + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.ExperimentalComposeUiApi + +@ExperimentalComposeUiApi +actual fun nativeKeyboardOptionsUseNativeInputHandling(enabled: Boolean): KeyboardOptions { + return KeyboardOptions() +} diff --git a/compose/mpp/demo/src/macosX64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosX64.kt b/compose/mpp/demo/src/macosX64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosX64.kt new file mode 100644 index 0000000000000..e704dd4e448f1 --- /dev/null +++ b/compose/mpp/demo/src/macosX64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosX64.kt @@ -0,0 +1,25 @@ +/* + * 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.mpp.demo.textfield + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.ExperimentalComposeUiApi + +@ExperimentalComposeUiApi +actual fun nativeKeyboardOptionsUseNativeInputHandling(enabled: Boolean): KeyboardOptions { + return KeyboardOptions() +} diff --git a/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.uikit.kt b/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.uikit.kt new file mode 100644 index 0000000000000..0ef1f50a2d46d --- /dev/null +++ b/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.uikit.kt @@ -0,0 +1,30 @@ +/* + * 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.mpp.demo.textfield + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.text.input.PlatformImeOptions + +@ExperimentalComposeUiApi +actual fun nativeKeyboardOptionsUseNativeInputHandling(enabled: Boolean): KeyboardOptions { + return KeyboardOptions( + platformImeOptions = PlatformImeOptions { + useNativeInputHandling(enabled) + } + ) +} diff --git a/compose/mpp/demo/src/wasmJsMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.wasmJs.kt b/compose/mpp/demo/src/wasmJsMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.wasmJs.kt new file mode 100644 index 0000000000000..e704dd4e448f1 --- /dev/null +++ b/compose/mpp/demo/src/wasmJsMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.wasmJs.kt @@ -0,0 +1,25 @@ +/* + * 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.mpp.demo.textfield + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.ExperimentalComposeUiApi + +@ExperimentalComposeUiApi +actual fun nativeKeyboardOptionsUseNativeInputHandling(enabled: Boolean): KeyboardOptions { + return KeyboardOptions() +} diff --git a/compose/ui/ui-text/src/uikitMain/kotlin/androidx/compose/ui/text/input/PlatformImeOptions.uikit.kt b/compose/ui/ui-text/src/uikitMain/kotlin/androidx/compose/ui/text/input/PlatformImeOptions.uikit.kt index cf4f3ab7cdc14..b5831e49a8821 100644 --- a/compose/ui/ui-text/src/uikitMain/kotlin/androidx/compose/ui/text/input/PlatformImeOptions.uikit.kt +++ b/compose/ui/ui-text/src/uikitMain/kotlin/androidx/compose/ui/text/input/PlatformImeOptions.uikit.kt @@ -43,6 +43,7 @@ private data class PlatformImeOptionsImpl( val inputView: UIView?, val inputAccessoryView: UIView?, val writingToolsBehavior: UIWritingToolsBehavior, + val useNativeInputHandling: Boolean, ): PlatformImeOptions() /** @@ -62,6 +63,7 @@ class PlatformImeOptionsConfiguration internal constructor() { private var inputView: UIView? = null private var inputAccessoryView: UIView? = null private var writingToolsBehavior: UIWritingToolsBehavior = UIWritingToolsBehaviorDefault + private var useNativeInputHandling: Boolean = false /** * Sets the keyboard type to be used for the text input field. * If not set, the value will be derived from [ImeOptions]. @@ -180,6 +182,15 @@ class PlatformImeOptionsConfiguration internal constructor() { writingToolsBehavior = value } + /** + * Enables or disables native input handling in Compose iOS text input pipeline. + * Default is false. + */ + @ExperimentalComposeUiApi + fun useNativeInputHandling(value: Boolean): PlatformImeOptionsConfiguration = apply { + useNativeInputHandling = value + } + /** * Builds the final PlatformImeOptions instance with the configured values. */ @@ -197,6 +208,7 @@ class PlatformImeOptionsConfiguration internal constructor() { inputView = inputView, inputAccessoryView = inputAccessoryView, writingToolsBehavior = writingToolsBehavior, + useNativeInputHandling = useNativeInputHandling, ) } } @@ -261,4 +273,8 @@ val PlatformImeOptions.inputAccessoryView: UIView? @ExperimentalComposeUiApi val PlatformImeOptions.writingToolsBehavior: UIWritingToolsBehavior - get() = (this as? PlatformImeOptionsImpl)?.writingToolsBehavior ?: UIWritingToolsBehaviorDefault \ No newline at end of file + get() = (this as? PlatformImeOptionsImpl)?.writingToolsBehavior ?: UIWritingToolsBehaviorDefault + +@ExperimentalComposeUiApi +val PlatformImeOptions.useNativeInputHandling: Boolean + get() = (this as? PlatformImeOptionsImpl)?.useNativeInputHandling ?: false \ No newline at end of file diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuCustomAction.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuCustomAction.h new file mode 100644 index 0000000000000..a2757faab0e9f --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuCustomAction.h @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import + +@interface CMPEditMenuCustomAction : NSObject + +@property (copy, nonatomic) NSString *title; +@property (copy, nonatomic) void (^actionBlock)(void); + +- (id)initWithTitle:(NSString *)title action:(void (^)(void))actionBlock; + +@end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuCustomAction.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuCustomAction.m new file mode 100644 index 0000000000000..db87dae2a615e --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuCustomAction.m @@ -0,0 +1,38 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "CMPEditMenuCustomAction.h" + +@implementation CMPEditMenuCustomAction + +- (id)initWithTitle:(NSString *)title action:(void (^)(void))actionBlock { + self = [super init]; + if (self) { + _title = title; + _actionBlock = actionBlock; + } + return self; +} + +- (BOOL)isEqual:(id)other { + return [self.title isEqualToString:((CMPEditMenuCustomAction *)other).title]; +} + +- (NSUInteger)hash { + return self.title.hash; +} + +@end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h index 0a6894de21d36..49d382c54456b 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h @@ -15,17 +15,10 @@ */ #import +#import "CMPEditMenuCustomAction.h" +#import "CMPTextInputView.h" -@interface CMPEditMenuCustomAction : NSObject - -@property (copy, nonatomic) NSString *title; -@property (copy, nonatomic) void (^actionBlock)(void); - -- (id)initWithTitle:(NSString *)title action:(void (^)(void))actionBlock; - -@end - -@interface CMPEditMenuView : UIView +@interface CMPEditMenuView : CMPTextInputView @property (readonly) BOOL isEditMenuShown; diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m index aee2ed98ede05..8a9a762e39e72 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m @@ -16,28 +16,6 @@ #import "CMPEditMenuView.h" -@implementation CMPEditMenuCustomAction - -- (id)initWithTitle:(NSString *)title action:(void (^)(void))actionBlock { - self = [super init]; - if (self) { - _title = title; - _actionBlock = actionBlock; - } - return self; -} - -- (BOOL)isEqual:(id)other { - return [self.title isEqualToString:((CMPEditMenuCustomAction *)other).title]; -} - -- (NSUInteger)hash { - return self.title.hash; -} - -@end - - @interface CMPEditMenuViewRegister: NSObject @property (nonatomic, strong) NSMutableSet *trackedMenus; @@ -322,20 +300,25 @@ - (BOOL)contextMenuItemsChangedCopy:(void (^)(void))copyBlock } - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { - return ((@selector(copy:) == action && self.copyBlock != nil) || - (@selector(paste:) == action && self.pasteBlock != nil) || - (@selector(cut:) == action && self.cutBlock != nil) || - (@selector(selectAll:) == action && self.selectAllBlock != nil) || - (@selector(customAction0:) == action && self.customActions.count > 0) || - (@selector(customAction1:) == action && self.customActions.count > 1) || - (@selector(customAction2:) == action && self.customActions.count > 2) || - (@selector(customAction3:) == action && self.customActions.count > 3) || - (@selector(customAction4:) == action && self.customActions.count > 4) || - (@selector(customAction5:) == action && self.customActions.count > 5) || - (@selector(customAction6:) == action && self.customActions.count > 6) || - (@selector(customAction7:) == action && self.customActions.count > 7) || - (@selector(customAction8:) == action && self.customActions.count > 8) || - (@selector(customAction9:) == action && self.customActions.count > 9)); + BOOL handled = ((@selector(copy:) == action && self.copyBlock != nil) || + (@selector(paste:) == action && self.pasteBlock != nil) || + (@selector(cut:) == action && self.cutBlock != nil) || + (@selector(selectAll:) == action && self.selectAllBlock != nil) || + (@selector(customAction0:) == action && self.customActions.count > 0) || + (@selector(customAction1:) == action && self.customActions.count > 1) || + (@selector(customAction2:) == action && self.customActions.count > 2) || + (@selector(customAction3:) == action && self.customActions.count > 3) || + (@selector(customAction4:) == action && self.customActions.count > 4) || + (@selector(customAction5:) == action && self.customActions.count > 5) || + (@selector(customAction6:) == action && self.customActions.count > 6) || + (@selector(customAction7:) == action && self.customActions.count > 7) || + (@selector(customAction8:) == action && self.customActions.count > 8) || + (@selector(customAction9:) == action && self.customActions.count > 9)); + + if (handled) { + return YES; + } + return [super canPerformAction:action withSender:sender]; } - (void)copy:(id)sender { diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputStringTokenizer.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputStringTokenizer.h new file mode 100644 index 0000000000000..29f1a439d3c0e --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputStringTokenizer.h @@ -0,0 +1,28 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface CMPTextInputStringTokenizer: UITextInputStringTokenizer + +- (BOOL)isPositionAtBoundary:(UITextPosition *)position atBoundary:(UITextGranularity)granularity inDirection:(UITextDirection)direction; +- (BOOL)isPositionWithinTextUnit:(UITextPosition *)position withinTextUnit:(UITextGranularity)granularity inDirection:(UITextDirection)direction; + +@end + +NS_ASSUME_NONNULL_END diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputStringTokenizer.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputStringTokenizer.m new file mode 100644 index 0000000000000..1e9a1901732e5 --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputStringTokenizer.m @@ -0,0 +1,37 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "CMPTextInputStringTokenizer.h" + +@implementation CMPTextInputStringTokenizer + +- (BOOL)isPosition:(UITextPosition *)position atBoundary:(UITextGranularity)granularity inDirection:(UITextDirection)direction { + return [self isPositionAtBoundary:position atBoundary:granularity inDirection:direction]; +} + +- (BOOL)isPosition:(UITextPosition *)position withinTextUnit:(UITextGranularity)granularity inDirection:(UITextDirection)direction { + return [self isPositionWithinTextUnit:position withinTextUnit:granularity inDirection:direction]; +} + +- (BOOL)isPositionAtBoundary:(UITextPosition *)position atBoundary:(UITextGranularity)granularity inDirection:(UITextDirection)direction { + return [super isPosition:position atBoundary:granularity inDirection:direction]; +} + +- (BOOL)isPositionWithinTextUnit:(UITextPosition *)position withinTextUnit:(UITextGranularity)granularity inDirection:(UITextDirection)direction { + return [super isPosition:position withinTextUnit:granularity inDirection:direction]; +} + +@end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputView.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputView.h new file mode 100644 index 0000000000000..6da078d171cb8 --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputView.h @@ -0,0 +1,29 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import +#import "CMPMacros.h" + +@interface CMPTextInputView : UIView + +NS_ASSUME_NONNULL_BEGIN +- (nullable UITextPosition *)positionWithinRangeFarthestInDirection:(UITextRange *)range + farthestInDirection:(UITextLayoutDirection)direction CMP_ABSTRACT_FUNCTION; +- (nullable UITextPosition *)positionWithinRangeAtCharacterOffset:(UITextRange *)range + atCharacterOffset:(NSInteger)offset CMP_ABSTRACT_FUNCTION; +NS_ASSUME_NONNULL_END + +@end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputView.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputView.m new file mode 100644 index 0000000000000..0b7e748c67a7c --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPTextInputView.m @@ -0,0 +1,128 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "CMPTextInputView.h" + +@implementation CMPTextInputView + +@synthesize beginningOfDocument; +@synthesize hasText; +@synthesize inputDelegate; +@synthesize markedTextRange; +@synthesize selectedTextRange; +@synthesize tokenizer; +@synthesize endOfDocument; +@synthesize markedTextStyle; + +- (UITextPosition *)positionWithinRange:(UITextRange *)range atCharacterOffset:(NSInteger)offset { + return [self positionWithinRangeAtCharacterOffset:range atCharacterOffset:offset]; +} + +- (UITextPosition *)positionWithinRange:(UITextRange *)range farthestInDirection:(UITextLayoutDirection)direction { + return [self positionWithinRangeFarthestInDirection:range farthestInDirection:direction]; +} + +- (NSWritingDirection)baseWritingDirectionForPosition:(nonnull UITextPosition *)position inDirection:(UITextStorageDirection)direction { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (CGRect)caretRectForPosition:(nonnull UITextPosition *)position { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nullable UITextRange *)characterRangeAtPoint:(CGPoint)point { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nullable UITextRange *)characterRangeByExtendingPosition:(nonnull UITextPosition *)position inDirection:(UITextLayoutDirection)direction { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nullable UITextPosition *)closestPositionToPoint:(CGPoint)point { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nullable UITextPosition *)closestPositionToPoint:(CGPoint)point withinRange:(nonnull UITextRange *)range { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (NSComparisonResult)comparePosition:(nonnull UITextPosition *)position toPosition:(nonnull UITextPosition *)other { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (CGRect)firstRectForRange:(nonnull UITextRange *)range { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (NSInteger)offsetFromPosition:(nonnull UITextPosition *)from toPosition:(nonnull UITextPosition *)toPosition { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nullable UITextPosition *)positionFromPosition:(nonnull UITextPosition *)position inDirection:(UITextLayoutDirection)direction offset:(NSInteger)offset { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nullable UITextPosition *)positionFromPosition:(nonnull UITextPosition *)position offset:(NSInteger)offset { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (void)replaceRange:(nonnull UITextRange *)range withText:(nonnull NSString *)text { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nonnull NSArray *)selectionRectsForRange:(nonnull UITextRange *)range { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (void)setBaseWritingDirection:(NSWritingDirection)writingDirection forRange:(nonnull UITextRange *)range { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (void)setMarkedText:(nullable NSString *)markedText selectedRange:(NSRange)selectedRange { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nullable NSString *)textInRange:(nonnull UITextRange *)range { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nullable UITextRange *)textRangeFromPosition:(nonnull UITextPosition *)fromPosition toPosition:(nonnull UITextPosition *)toPosition { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (void)unmarkText { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nullable UITextPosition *)positionWithinRangeFarthestInDirection:(UITextRange *)range + farthestInDirection:(UITextLayoutDirection)direction { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (nullable UITextPosition *)positionWithinRangeAtCharacterOffset:(UITextRange *)range + atCharacterOffset:(NSInteger)offset { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (void)deleteBackward { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (void)insertText:(nonnull NSString *)text { + CMP_ABSTRACT_FUNCTION_CALLED +} + +@end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h index 9dd2494e0ab93..62ede85d55fce 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h @@ -34,5 +34,8 @@ FOUNDATION_EXPORT const unsigned char CMPUIKitUtilsVersionString[]; #import "CMPPanGestureRecognizer.h" #import "CMPHoverGestureHandler.h" #import "CMPScreenEdgePanGestureRecognizer.h" +#import "CMPTextInputStringTokenizer.h" #import "CMPScrollView.h" #import "CMPComposeContainerLifecycleDelegate.h" +#import "CMPTextInputView.h" +#import "CMPEditMenuCustomAction.h" 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 43f839877596d..75582450d6cb9 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.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.input.EditCommand @@ -82,6 +83,9 @@ actual interface PlatformTextInputMethodRequest { @ExperimentalComposeUiApi val textClippingRectInRoot: () -> Rect? + @ExperimentalComposeUiApi + val textUnclippingOffsetInRoot: () -> Offset? + /** * Allows the text input service to edit the text. */ diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt index 241cefa0b0e99..c9adcf5b0a27a 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt @@ -18,6 +18,7 @@ package androidx.compose.ui.platform import androidx.compose.ui.text.TextRange import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect internal interface IOSSkikoInput { @@ -134,4 +135,28 @@ internal interface IOSSkikoInput { * Returned value must be in range between 0 and length of the text (inclusive). */ fun verticalPositionFromPosition(position: Int, verticalOffset: Int): Int? + + fun currentFocusedDpRect(): DpRect? + + fun caretDpRectForPosition(position: Int): DpRect? + + fun selectionDpRectsForRange(range: TextRange): List + + fun firstSelectionRectForRange(range: TextRange): DpRect? + + fun closestPositionToPoint(point: DpOffset): Int? + + fun closestPositionToPoint(point: DpOffset, withinRange: TextRange): Int? + + fun characterRangeAtPoint(point: DpOffset): TextRange? + + fun positionWithinRange(range: TextRange, atCharacterOffset: Int): Int? + + fun positionWithinRange(range: TextRange, farthestIndirection: String): Int? + + fun characterRangeByExtendingPosition(position: Int, direction: String): TextRange? + + fun baseWritingDirectionForPosition(position: Int, inDirection: String): String? + + fun offset(fromPosition: Int, toPosition: Int): Int } \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitNativeTextInputContext.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitNativeTextInputContext.kt new file mode 100644 index 0000000000000..84f584225f04e --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitNativeTextInputContext.kt @@ -0,0 +1,36 @@ +/* + * 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.ui.platform + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.uikit.utils.CMPEditMenuCustomAction + +interface UIKitNativeTextInputContext { + fun usingNativeInput(): Boolean + + fun updateEditMenuState( + targetRect: Rect, + copy: (() -> Unit)?, + paste: (() -> Unit)?, + cut: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List + ) + + fun updateTintColor(color: Color) +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt index a46818d8a2ecf..cbca24fb210d8 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt @@ -33,29 +33,42 @@ import androidx.compose.ui.text.input.EditProcessor import androidx.compose.ui.text.input.FinishComposingTextCommand import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.useNativeInputHandling import androidx.compose.ui.text.input.PlatformTextInputService import androidx.compose.ui.text.input.SetComposingRegionCommand import androidx.compose.ui.text.input.SetComposingTextCommand import androidx.compose.ui.text.input.SetSelectionCommand import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.uikit.density +import androidx.compose.ui.uikit.utils.CMPEditMenuCustomAction +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.asCGRect import androidx.compose.ui.unit.asDpOffset import androidx.compose.ui.unit.toDpRect import androidx.compose.ui.unit.toOffset +import androidx.compose.ui.unit.toSize import androidx.compose.ui.window.FocusedViewsList import androidx.compose.ui.window.IntermediateTextInputUIView import androidx.compose.ui.window.BackgroundInputView import androidx.compose.ui.window.OverlayInputView +import androidx.compose.ui.window.IntermediateTextScrollView import kotlin.math.absoluteValue +import kotlin.math.max import kotlin.math.min +import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents import kotlinx.coroutines.MainScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.skia.BreakIterator import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGRectZero +import platform.UIKit.UIColor import platform.UIKit.UIPress import platform.UIKit.UIView import platform.UIKit.UIViewAutoresizingFlexibleHeight @@ -78,13 +91,22 @@ internal class UIKitTextInputService( */ private var onKeyboardPresses: (Set<*>) -> Unit, private var focusManager: () -> ComposeSceneFocusManager? -) : PlatformTextInputService, TextToolbar { +) : PlatformTextInputService, TextToolbar, UIKitNativeTextInputContext { + + var useNativeInputHandling: Boolean = false + private set private var currentOnEditCommand: ((List) -> Unit)? = null private var currentImeOptions: ImeOptions? = null private var currentImeActionHandler: ((ImeAction) -> Unit)? = null private var textUIView: IntermediateTextInputUIView? = null - private var textLayoutResult : TextLayoutResult? = null + private var scrollView = IntermediateTextScrollView() + private var textLayoutResult: TextLayoutResult? = null + set(value) { + field = value + } + + private var currentFocusedRect: Rect? = null /** * Workaround to prevent calling textWillChange, textDidChange, selectionWillChange, and @@ -122,18 +144,9 @@ internal class UIKitTextInputService( */ private var _tempHardwareReturnKeyPressed: Boolean = false private var _tempImeActionIsCalledWithHardwareReturnKey: Boolean = false - - /** - * Workaround to fix voice dictation. - * UIKit call insertText(text) and replaceRange(range,text) immediately, - * but Compose recomposition happen on next draw frame. - * So the value of getSelectedTextRange is in the old state when the replaceRange function is called. - * @see _tempCursorPos helps to fix this behaviour. Permanently update _tempCursorPos in function insertText. - * And after clear in updateState function. - */ - private var _tempCursorPos: Int? = null private val mainScope = MainScope() + @OptIn(ExperimentalComposeUiApi::class) override fun startInput( value: TextFieldValue, imeOptions: ImeOptions, @@ -145,6 +158,7 @@ internal class UIKitTextInputService( } currentOnEditCommand = onEditCommand currentImeOptions = imeOptions + useNativeInputHandling = imeOptions.platformImeOptions?.useNativeInputHandling ?: false currentImeActionHandler = onImeActionPerformed attachIntermediateTextInputView() @@ -160,11 +174,15 @@ internal class UIKitTextInputService( sessionEditProcessor = null currentImeOptions = null currentImeActionHandler = null + textLayoutResult = null + hideSoftwareKeyboard() textUIView?.inputTraits = EmptyInputTraits textUIView?.input = null + detachIntermediateTextInputView() + useNativeInputHandling = false } override fun showSoftwareKeyboard() { @@ -189,10 +207,7 @@ internal class UIKitTextInputService( if (selectionChanged) { textUIView?.selectionWillChange() } - sessionEditProcessor?.let { - it.reset(newValue, null) - _tempCursorPos = null - } + sessionEditProcessor?.reset(newValue, null) if (textChanged) { textUIView?.textDidChange() } @@ -214,10 +229,64 @@ internal class UIKitTextInputService( } fun updateTextFrame(rect: Rect) { - textUIView?.setFrame(rect.toDpRect(view.density).asCGRect()) + if (useNativeInputHandling) { + textFieldFrameInRoot = rect + } else { + textUIView?.setFrame(rect.toDpRect(view.density).asCGRect()) + } showMenuOrUpdatePosition() } + private fun calculateContentBounds(textLayoutResult: TextLayoutResult, textFieldFrame: Rect, unclippingTextPosition: Offset): Rect { + val textSize = textLayoutResult.size.toSize() + val contentBounds = Rect( + offset = Offset(x = textFieldFrame.left - unclippingTextPosition.x, y = textFieldFrame.top - unclippingTextPosition.y), + size = textSize + ) + return contentBounds + } + + private fun calculateContentInsets(textFieldFrame: Rect, contentBounds: Rect): TextInsets { + return TextInsets( + left = max(0f, -contentBounds.left), + top = max(0f, -contentBounds.top), + right = max(0f, textFieldFrame.width - contentBounds.width + contentBounds.left), + bottom = max(0f, textFieldFrame.height - contentBounds.height + contentBounds.top) + ) + } + + fun updateFocusedRect(rect: Rect) { + currentFocusedRect = rect + } + + private var textFieldFrameInRoot: Rect? = null + private var clippingTextFrame: Rect? = null + private var currentContentBounds: Rect? = null + private var currentContentInsets: TextInsets? = null + fun updateClippingTextFrame(rect: Rect) { + clippingTextFrame = rect + } + + fun updateUnclippingTextPosition(offset: Offset) { + if (useNativeInputHandling) { + val rect = textFieldFrameInRoot ?: return + val layoutResult = textLayoutResult ?: return + val contentBounds = calculateContentBounds( + layoutResult, + rect, + offset + ) + currentContentBounds = contentBounds + val contentInsets = calculateContentInsets(rect, contentBounds) + currentContentInsets = contentInsets + scrollView.setFrame( + rect.toDpRect(view.density), + contentBounds.toDpRect(view.density), + contentInsets.toPlatformInsets(view.density) // TODO: check if this is correct + ) + } + } + fun updateTextLayoutResult(textLayoutResult: TextLayoutResult) { this.textLayoutResult = textLayoutResult } @@ -279,17 +348,6 @@ internal class UIKitTextInputService( } } - private fun getCursorPos(): Int? { - if (_tempCursorPos != null) { - return _tempCursorPos - } - val selection = getState()?.selection - if (selection != null && selection.start == selection.end) { - return selection.start - } - return null - } - private fun imeActionRequired(): Boolean = currentImeOptions?.run { singleLine || ( @@ -334,12 +392,61 @@ internal class UIKitTextInputService( // Fixes a problem where the menu is shown before the textUIView gets its final layout. private var showMenuOrUpdatePosition = {} + override fun showMenu( rect: Rect, onCopyRequested: (() -> Unit)?, onPasteRequested: (() -> Unit)?, onCutRequested: (() -> Unit)?, onSelectAllRequested: (() -> Unit)? + ) { + showMenu( + rect = rect, + onCopyRequested = onCopyRequested, + onPasteRequested = onPasteRequested, + onCutRequested = onCutRequested, + onSelectAllRequested = onSelectAllRequested, + onAutofillRequested = null + ) + } + + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)?, + onAutofillRequested: (() -> Unit)? + ) { + if (useNativeInputHandling) { + textUIView?.updateMenuActions( + onCopyRequested, + onPasteRequested, + onCutRequested, + onSelectAllRequested, + emptyList() + ) + } else { + showEditMenu( + rect, + onCopyRequested, + onPasteRequested, + onCutRequested, + onSelectAllRequested, + onAutofillRequested, + emptyList() + ) + } + } + + private fun showEditMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)?, + onAutofillRequested: (() -> Unit)?, + customActions: List ) { if (textUIView == null) { // If showMenu() is called and textUIView is not created, @@ -353,14 +460,13 @@ internal class UIKitTextInputService( val density = view.density val offset = textUIView.frame.useContents { origin.asDpOffset().toOffset(density) } val target = rect.translate(-offset).toDpRect(density).asCGRect() - textUIView.showTextMenu( + textUIView.showEditMenuAtRect( targetRect = target, - textActions = object : TextActions { - override val copy: (() -> Unit)? = onCopyRequested - override val cut: (() -> Unit)? = onCutRequested - override val paste: (() -> Unit)? = onPasteRequested - override val selectAll: (() -> Unit)? = onSelectAllRequested - } + copy = onCopyRequested, + cut = onCutRequested, + paste = onPasteRequested, + selectAll = onSelectAllRequested, + customActions = customActions ) textMenuAppearanceChanged() } @@ -383,41 +489,119 @@ internal class UIKitTextInputService( } } - override val status: TextToolbarStatus - get() = if (textUIView?.isTextMenuShown() == true) - TextToolbarStatus.Shown - else - TextToolbarStatus.Hidden + override fun updateEditMenuState( + targetRect: Rect, + copy: (() -> Unit)?, + paste: (() -> Unit)?, + cut: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List + ) { + if (useNativeInputHandling) { + textUIView?.updateMenuActions(copy, paste, cut, selectAll, customActions) + } else { + showEditMenu( + rect = targetRect, + onCopyRequested = copy, + onPasteRequested = paste, + onCutRequested = cut, + onSelectAllRequested = selectAll, + onAutofillRequested = null, + customActions = customActions + ) + } + } + + override fun usingNativeInput(): Boolean = useNativeInputHandling + + override fun updateTintColor(color: Color) { + textUIView?.let { + val uiColor = color.toUIColor() + if (it.tintColor != uiColor) { + it.setTintColor(uiColor) + } + } + } + + // The Menu appearance is controlled by UIKit. + // Return `Hidden` to make Compose always provide a new set of actions when selection changes. + override val status: TextToolbarStatus get() = TextToolbarStatus.Hidden private fun attachIntermediateTextInputView() { detachIntermediateTextInputView() - showMenuOrUpdatePosition = {} - textUIView = IntermediateTextInputUIView( - doubleTapTimeoutMillis = viewConfiguration.doubleTapTimeoutMillis - ).also { - it.setAutoresizingMask( - UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight - ) - it.onKeyboardPresses = onKeyboardPresses - view.addSubview(it) - it.setFrame(view.bounds) + if (useNativeInputHandling) { + textUIView = IntermediateTextInputUIView( + doubleTapTimeoutMillis = viewConfiguration.doubleTapTimeoutMillis, + usingNITI = useNativeInputHandling + ).also { + view.addSubview(scrollView) + +// it.setTintColor(UIColor.blackColor) // forward colors here + scrollView.textView = it + +// scrollView.backgroundColor = if (useNativeInputHandling) UIColor.redColor.colorWithAlphaComponent(0.5) else UIColor.clearColor +// it.backgroundColor = if (useNativeInputHandling) UIColor.yellowColor.colorWithAlphaComponent(0.2) else UIColor.clearColor + + it.onKeyboardPresses = onKeyboardPresses + it.clipsToBounds = false + it.input = createSkikoInput() + it.inputTraits = getUITextInputTraits(currentImeOptions) + + // Resizing should be done later + // TODO: Check selection container + it.resignFirstResponder() + it.becomeFirstResponder() + } + } else { + textUIView = IntermediateTextInputUIView( + doubleTapTimeoutMillis = viewConfiguration.doubleTapTimeoutMillis, + usingNITI = useNativeInputHandling + ).also { + it.setAutoresizingMask( + UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight + ) + it.onKeyboardPresses = onKeyboardPresses + view.addSubview(it) + it.setFrame(view.bounds) + } } } private fun detachIntermediateTextInputView() { - showMenuOrUpdatePosition = {} - textUIView?.let { view -> - val outOfBoundsFrame = CGRectMake(-100000.0, 0.0, 1.0, 1.0) - // Set out-of-bounds non-empty frame to hide text keyboard focus frame - view.setFrame(outOfBoundsFrame) - - view.resetOnKeyboardPressesCallback() - mainScope.launch { - delay(CLEAR_FOCUS_DELAY) - view.removeFromSuperview() + if (useNativeInputHandling) { + textUIView?.input = null + textUIView?.inputTraits = EmptyInputTraits + + textUIView?.let { view -> + val outOfBoundsFrame = CGRectMake(-100000.0, 0.0, 1.0, 1.0) + // Set out-of-bounds non-empty frame to hide text keyboard focus frame + view.setFrame(outOfBoundsFrame) + + view.resetOnKeyboardPressesCallback() + mainScope.launch { + delay(CLEAR_FOCUS_DELAY) + if (scrollView.textView == view) { + scrollView.textView = null + } + } } + scrollView.setFrame(CGRectZero.readValue()) + textUIView = null + } else { + showMenuOrUpdatePosition = {} + textUIView?.let { view -> + val outOfBoundsFrame = CGRectMake(-100000.0, 0.0, 1.0, 1.0) + // Set out-of-bounds non-empty frame to hide text keyboard focus frame + view.setFrame(outOfBoundsFrame) + + view.resetOnKeyboardPressesCallback() + mainScope.launch { + delay(CLEAR_FOCUS_DELAY) + view.removeFromSuperview() + } + } + textUIView = null } - textUIView = null } fun dispose() { @@ -454,7 +638,7 @@ internal class UIKitTextInputService( } override fun beginFloatingCursor(offset: DpOffset) { - val cursorPos = getCursorPos() ?: getState()?.selection?.start ?: return + val cursorPos = getState()?.selection?.start ?: return val cursorRect = textLayoutResult?.getCursorRect(cursorPos) ?: return floatingCursorTranslation = cursorRect.center - offset.toOffset(view.density) } @@ -498,9 +682,6 @@ internal class UIKitTextInputService( return } } - getCursorPos()?.let { - _tempCursorPos = it + text.length - } sendEditCommand(CommitTextCommand(text, 1)) } @@ -677,7 +858,197 @@ internal class UIKitTextInputService( } } + override fun currentFocusedDpRect(): DpRect? = currentFocusedRect?.toDpRect(view.density) + + override fun caretDpRectForPosition(position: Int): DpRect? { + val text = getState()?.text ?: return null + if (position < 0 || position > text.length) { + return null + } + val currentTextLayoutResult = textLayoutResult ?: return null + if (position > currentTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) { + return null + } + val rect = currentTextLayoutResult.getCursorRect(position) + return rect.toDpRect(view.density) + } + + override fun selectionDpRectsForRange(range: TextRange): List { + // Native selection rects are required for correct work of the text editing menu + // Without them, it will be impossible to call the text editing menu by tapping on the selected area + if (range.collapsed || isIncorrect(range)) { + return emptyList() + } + val currentTextLayoutResult = textLayoutResult ?: return emptyList() + + val startSelectionHandleRect = currentTextLayoutResult.getCursorRect(range.start) + val endSelectionHandleRect = currentTextLayoutResult.getCursorRect(range.end) + + val firstLineNumber = currentTextLayoutResult.getLineForOffset(range.start) + val lastLineNumber = currentTextLayoutResult.getLineForOffset(range.end) + + return if (firstLineNumber == lastLineNumber) { + listOf( + TextSelectionRect( + dpRect = Rect( + topLeft = startSelectionHandleRect.topLeft, + bottomRight = endSelectionHandleRect.bottomRight + ).toDpRect(view.density), + writingDirection = TextDirection.Content, + containsStart = true, + containsEnd = true, + isVertical = false + ) + ) + } else { + // TODO Consider RTL Layout + // We require separate rects for start line, end line and everything in between them + val contentInsets = currentContentInsets ?: return emptyList() + val contentRect = currentContentBounds?.let { + Rect( + top = it.top + contentInsets.top, + left = it.left + contentInsets.left, + right = it.right + contentInsets.right, + bottom = it.bottom + contentInsets.bottom + ) + } ?: return emptyList() + + val firstLineEndRect = currentTextLayoutResult.getCursorRect( + currentTextLayoutResult.getLineEnd(firstLineNumber) + ) + val firstLineSelectionRect = TextSelectionRect( + dpRect = Rect( + top = startSelectionHandleRect.top, + left = startSelectionHandleRect.left, + right = contentRect.right, + bottom = startSelectionHandleRect.bottom + ).toDpRect(view.density), + writingDirection = TextDirection.Content, + containsStart = true, + containsEnd = false, + isVertical = false + ) + + val middleAreaSelectionRect = TextSelectionRect( + dpRect = Rect( + top = startSelectionHandleRect.bottom, + left = contentRect.left, + right = contentRect.right, + bottom = endSelectionHandleRect.top + ).toDpRect(view.density), + writingDirection = TextDirection.Content, + containsStart = false, + containsEnd = false, + isVertical = false + ) + + val lastLineStartRect = currentTextLayoutResult.getCursorRect( + currentTextLayoutResult.getLineStart(lastLineNumber) + ) + val lastLineRect = TextSelectionRect( + dpRect = Rect( + topLeft = lastLineStartRect.topLeft, + bottomRight = endSelectionHandleRect.bottomRight + ).toDpRect(view.density), + writingDirection = TextDirection.Content, + containsStart = false, + containsEnd = true, + isVertical = false + ) + + listOf( + firstLineSelectionRect, + middleAreaSelectionRect, + lastLineRect + ) + } + } + + override fun firstSelectionRectForRange(range: TextRange): DpRect? { + if (range.collapsed && isIncorrect(range)) { + return null + } + val currentTextLayoutResult = textLayoutResult ?: return null + + val startHandleLineNumber = currentTextLayoutResult.getLineForOffset(range.start) + val endHandleLineNumber = currentTextLayoutResult.getLineForOffset(range.end) + + val startHandleRect = currentTextLayoutResult.getCursorRect(range.start) + + return if (startHandleLineNumber == endHandleLineNumber) { + Rect( + topLeft = startHandleRect.topLeft, + bottomRight = currentTextLayoutResult.getCursorRect(range.end).bottomRight + ).toDpRect(view.density) + } else { + val startLineNumber = currentTextLayoutResult.getLineForOffset(range.start) + val startLineRight = currentTextLayoutResult.getLineRight(startLineNumber) + Rect( + startHandleRect.left, + startHandleRect.top, + startLineRight, + startHandleRect.bottom + ).toDpRect(view.density) + } + } + + override fun closestPositionToPoint(point: DpOffset): Int? { + return textLayoutResult?.getOffsetForPosition(point.toOffset(view.density)) + } + + override fun closestPositionToPoint(point: DpOffset, withinRange: TextRange): Int? { + val pointOffset = + textLayoutResult?.getOffsetForPosition(point.toOffset(view.density)) + ?: return null + return pointOffset.coerceIn(withinRange.start, withinRange.end) + } + + override fun characterRangeAtPoint(point: DpOffset): TextRange? { + val pointOffset = + textLayoutResult?.getOffsetForPosition(point.toOffset(view.density)) + ?: return null + return textLayoutResult?.getWordBoundary(pointOffset) + } + + override fun positionWithinRange(range: TextRange, atCharacterOffset: Int): Int? { + TODO("Not yet implemented") + } + + override fun positionWithinRange(range: TextRange, farthestIndirection: String): Int? { + TODO("Not yet implemented") + } + + override fun characterRangeByExtendingPosition( + position: Int, + direction: String + ): TextRange? { + TODO("Not yet implemented") + } + + override fun baseWritingDirectionForPosition(position: Int, inDirection: String): String? { + TODO("Not yet implemented") + } + + override fun offset(fromPosition: Int, toPosition: Int): Int { + TODO("Not yet implemented") + } + private fun isIncorrect(range: TextRange): Boolean = range.start < 0 || range.end > endOfDocument() || range.start > range.end } } + +internal data class TextSelectionRect( + val dpRect: DpRect, + val writingDirection: TextDirection, + val containsStart: Boolean, + val containsEnd: Boolean, + val isVertical: Boolean +) + +// Text insets without applied density +private data class TextInsets(val left: Float, val top: Float, val right: Float, val bottom: Float) +private fun TextInsets.toPlatformInsets(density: Density): PlatformInsets { + return PlatformInsets(left = (left / density.density).toInt(), top = (top / density.density).toInt(), right = (right / density.density).toInt(), bottom = (bottom / density.density).toInt()) +} + diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt index f8feb28ae2110..29563a5776659 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt @@ -61,6 +61,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.LocalUIView import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.uikit.density @@ -601,6 +602,7 @@ internal class ComposeSceneMediator( CompositionLocalProvider( LocalInteropContainer provides interopContainer, LocalUIView provides _overlayView, + LocalNativeTextInputContext provides textInputService, content = content ) @@ -743,11 +745,26 @@ internal class ComposeSceneMediator( textInputService.updateTextLayoutResult(it) } } + launch { + snapshotFlow { request.textClippingRectInRoot() }.filterNotNull().collect { + textInputService.updateClippingTextFrame(it) + } + } launch { snapshotFlow { request.textFieldRectInRoot() }.filterNotNull().collect { textInputService.updateTextFrame(it) } } + launch { + snapshotFlow { request.focusedRectInRoot() }.filterNotNull().collect { + textInputService.updateFocusedRect(it) + } + } + launch { + snapshotFlow { request.textUnclippingOffsetInRoot() }.filterNotNull().collect { + textInputService.updateUnclippingTextPosition(it) + } + } suspendCancellableCoroutine { continuation -> textInputService.startInput( value = request.value(), diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/UIKitCompositionLocals.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/UIKitCompositionLocals.uikit.kt index 28f4c098bf63c..97dc9f7b062f2 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/UIKitCompositionLocals.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/UIKitCompositionLocals.uikit.kt @@ -17,6 +17,8 @@ package androidx.compose.ui.uikit import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.UIKitNativeTextInputContext import platform.UIKit.UIView import platform.UIKit.UIViewController @@ -39,3 +41,8 @@ val LocalUIViewController = staticCompositionLocalOf { val LocalUIView = staticCompositionLocalOf { error("CompositionLocal UIView not provided") } + +@ExperimentalComposeUiApi +val LocalNativeTextInputContext = staticCompositionLocalOf { + error("CompositionLocal UIKitTextContextMenuHandler not provided") +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/InputViews.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/InputViews.uikit.kt index 5541dce97b95b..cf83850c6d28f 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/InputViews.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/InputViews.uikit.kt @@ -154,8 +154,8 @@ private class TouchesGestureRecognizer( } } - val interactionMode = touchesToInteractionMode.values.map { - it?.findAncestorInteropWrappingView()?.interactionMode + val interactionMode = touchesToInteractionMode.map { + it.value?.findAncestorInteractionMode(it.key) }.findMostRestrictedInteractionMode() when (interactionMode) { is UIKitInteropInteractionMode.Cooperative -> { @@ -584,6 +584,15 @@ internal class OverlayInputView( // Interop view is located inside another container. return null } + val nativeTextInputViewHitTest = subviews.firstNotNullOfOrNull { it -> + (it as? IntermediateTextScrollView)?.let { + val inputPoint = convertPoint(point, toView = it) + it.hitTest(inputPoint, withEvent) + } + } + if (nativeTextInputViewHitTest != null) { + return nativeTextInputViewHitTest + } return super.hitTest(point, withEvent) } @@ -722,11 +731,14 @@ internal class BackgroundInputView( * query. This extension method allows finding the nearest [InteropWrappingView] up the view * hierarchy and request the value retroactively. */ -private fun UIView.findAncestorInteropWrappingView(): InteropWrappingView? { +private fun UIView.findAncestorInteractionMode(touch: UITouch): UIKitInteropInteractionMode? { var view: UIView? = this while (view != null) { if (view is InteropWrappingView) { - return view + return view.interactionMode + } + if (view is IntermediateTextScrollView) { + return view.interactionModeAt(touch.locationInView(view)) } view = view.superview } @@ -745,6 +757,7 @@ private fun UIView?.hasTrackingUIScrollView(): Boolean { } if (view is UIScrollView && view.userInteractionEnabled && + view.scrollEnabled && view.panGestureRecognizer.isEnabled()) { if ((view.panGestureRecognizer.state == UIGestureRecognizerStatePossible || view.panGestureRecognizer.state == UIGestureRecognizerStateBegan) && diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt index b13ff59a9e30d..5255fc84f9e07 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt @@ -18,48 +18,86 @@ package androidx.compose.ui.window import androidx.compose.ui.platform.EmptyInputTraits import androidx.compose.ui.platform.IOSSkikoInput +import androidx.compose.ui.platform.PlatformInsets import androidx.compose.ui.platform.SkikoUITextInputTraits import androidx.compose.ui.platform.TextActions +import androidx.compose.ui.platform.TextSelectionRect import androidx.compose.ui.text.TextRange import androidx.compose.ui.uikit.utils.CMPEditMenuView +import androidx.compose.ui.uikit.utils.CMPTextInputView +import androidx.compose.ui.uikit.utils.CMPEditMenuCustomAction +import androidx.compose.ui.uikit.utils.CMPGestureRecognizer +import androidx.compose.ui.uikit.utils.CMPTextInputStringTokenizer import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.asCGRect import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastMap +import androidx.compose.ui.viewinterop.UIKitInteropInteractionMode import kotlin.time.Duration.Companion.milliseconds import kotlin.time.DurationUnit +import kotlinx.cinterop.COpaquePointer import kotlinx.cinterop.CValue -import kotlinx.cinterop.ObjCSignatureOverride import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +import org.jetbrains.skia.BreakIterator +import org.jetbrains.skiko.OS +import org.jetbrains.skiko.OSVersion +import org.jetbrains.skiko.available import platform.CoreGraphics.CGPoint +import platform.CoreGraphics.CGPointEqualToPoint import platform.CoreGraphics.CGRect +import platform.CoreGraphics.CGRectContainsPoint +import platform.CoreGraphics.CGRectEqualToRect +import platform.CoreGraphics.CGRectGetHeight +import platform.CoreGraphics.CGRectGetWidth +import platform.CoreGraphics.CGRectInset import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGRectNull import platform.CoreGraphics.CGRectZero +import platform.CoreGraphics.CGSizeEqualToSize +import platform.Foundation.NSArray import platform.Foundation.NSComparisonResult import platform.Foundation.NSDictionary import platform.Foundation.NSOrderedAscending import platform.Foundation.NSOrderedDescending import platform.Foundation.NSOrderedSame import platform.Foundation.NSRange +import platform.Foundation.NSStringFromSelector import platform.Foundation.dictionary +import platform.QuartzCore.CAShapeLayer import platform.UIKit.NSWritingDirection import platform.UIKit.NSWritingDirectionNatural +import platform.UIKit.UIAction +import platform.UIKit.UIEdgeInsetsEqualToEdgeInsets +import platform.UIKit.UIEdgeInsetsMake import platform.UIKit.UIEvent +import platform.UIKit.UIGestureRecognizer import platform.UIKit.UIKeyInputProtocol import platform.UIKit.UIKeyboardAppearance import platform.UIKit.UIKeyboardType +import platform.UIKit.UIMenu +import platform.UIKit.UIMenuAutoFill +import platform.UIKit.UIMenuBuilderProtocol +import platform.UIKit.UIMenuElement +import platform.UIKit.UIMenuItem import platform.UIKit.UIPress import platform.UIKit.UIPressesEvent +import platform.UIKit.UIResponder import platform.UIKit.UIReturnKeyType +import platform.UIKit.UIScrollView import platform.UIKit.UITextAutocapitalizationType import platform.UIKit.UITextAutocorrectionType import platform.UIKit.UITextContentType +import platform.UIKit.UITextDirection +import platform.UIKit.UITextGranularity import platform.UIKit.UITextInputDelegateProtocol import platform.UIKit.UITextInputProtocol -import platform.UIKit.UITextInputStringTokenizer import platform.UIKit.UITextInputTokenizerProtocol +import platform.UIKit.UITextInteraction +import platform.UIKit.UITextInteractionMode import platform.UIKit.UITextLayoutDirection import platform.UIKit.UITextLayoutDirectionDown import platform.UIKit.UITextLayoutDirectionLeft @@ -69,9 +107,14 @@ import platform.UIKit.UITextPosition import platform.UIKit.UITextRange import platform.UIKit.UITextSelectionRect import platform.UIKit.UITextStorageDirection +import platform.UIKit.UITextStorageDirectionForward +import platform.UIKit.UITextWritingDirection +import platform.UIKit.UITouch import platform.UIKit.UIView import platform.UIKit.UIWritingToolsBehavior import platform.UIKit.UIWritingToolsResultOptions +import platform.UIKit.addInteraction +import platform.UIKit.removeInteraction import platform.darwin.NSInteger private val NoOpOnKeyboardPresses: (Set<*>) -> Unit = {} @@ -80,7 +123,8 @@ private val NoOpOnKeyboardPresses: (Set<*>) -> Unit = {} * TODO maybe need to call reloadInputViews() to update UIKit text features? */ internal class IntermediateTextInputUIView( - private val doubleTapTimeoutMillis: Long + private val doubleTapTimeoutMillis: Long, + private val usingNITI: Boolean ) : CMPEditMenuView(frame = CGRectZero.readValue()), UIKeyInputProtocol, UITextInputProtocol { private var _inputDelegate: UITextInputDelegateProtocol? = null @@ -88,12 +132,18 @@ internal class IntermediateTextInputUIView( set(value) { field = value if (value == null) { - hideEditMenu() + resignFirstResponder() } } private val mainScope = MainScope() + private val touchesTrackerGestureRecognizer = TouchTrackingGestureRecognizer().also { + if (usingNITI) { + addGestureRecognizer(it) + } + } + /** * Callback to handle keyboard presses. The parameter is a [Set] of [UIPress] objects. * Erasure happens due to K/N not supporting Obj-C lightweight generics. @@ -107,6 +157,48 @@ internal class IntermediateTextInputUIView( override fun canBecomeFirstResponder() = true + private val selectionInteraction = + UITextInteraction.textInteractionForMode(UITextInteractionMode.UITextInteractionModeEditable) + .also { + it.setTextInput(this) + } + + + override fun layoutSubviews() { + super.layoutSubviews() + + if (usingNITI) { + hideCursorView() + } + } + + private var selectionInteractionAttached: Boolean = false + + override fun didMoveToWindow() { + super.didMoveToWindow() + if (usingNITI) { + if (window != null && !selectionInteractionAttached) { + // Ensure UIKit text interaction is attached early so that cursor and selection + // handles can be created and shown when needed. + this.addInteraction(selectionInteraction) + selectionInteractionAttached = true + } + } + } + + override fun becomeFirstResponder(): Boolean { + val isFirstResponder = this.isFirstResponder() + val result = super.becomeFirstResponder() + + if (usingNITI) { + if (!isFirstResponder && this.isFirstResponder()) { + this.addInteraction(selectionInteraction) + } + } + + return result + } + override fun resignFirstResponder(): Boolean { input?.onResignFocus() hideTextMenu() @@ -127,7 +219,6 @@ internal class IntermediateTextInputUIView( override fun pressesBegan(presses: Set<*>, withEvent: UIPressesEvent?) { onKeyboardPresses(presses) - super.pressesBegan(presses, withEvent) } @@ -137,10 +228,14 @@ internal class IntermediateTextInputUIView( } override fun hitTest(point: CValue, withEvent: UIEvent?): UIView? { - return if (input == null) { - null + if (usingNITI) { + return if (input == null) { + null + } else { + super.hitTest(point, withEvent) + } } else { - super.hitTest(point, withEvent) + return super.hitTest(point, withEvent) } } @@ -202,8 +297,27 @@ internal class IntermediateTextInputUIView( } override fun setSelectedTextRange(selectedTextRange: UITextRange?) { - input?.withBatch { - input?.setSelectedTextRange(selectedTextRange?.toTextRange()) + if (usingNITI) { + val range = selectedTextRange?.toTextRange() + if (input?.getSelectedTextRange() != range) { + // iOS <= 16 does not update selection handles when selection changes from the keyboard + // Posting an extra notification solves this issue + val notifySelectionChanges = !available(OS.Ios to OSVersion(major = 17)) && + !touchesTrackerGestureRecognizer.isTrackingTouches + if (notifySelectionChanges) { + selectionWillChange() + } + input?.withBatch { + input?.setSelectedTextRange(range) + } + if (notifySelectionChanges) { + selectionDidChange() + } + } + } else { + input?.withBatch { + input?.setSelectedTextRange(selectedTextRange?.toTextRange()) + } } } @@ -369,17 +483,19 @@ internal class IntermediateTextInputUIView( return (toPosition.position - from.position).toLong() } - @ObjCSignatureOverride - override fun positionWithinRange( + override fun positionWithinRangeAtCharacterOffset( range: UITextRange, atCharacterOffset: NSInteger - ): UITextPosition? = null // TODO positionWithinRange + ): UITextPosition { + return IntermediateTextPosition(0) + } - @ObjCSignatureOverride - override fun positionWithinRange( + override fun positionWithinRangeFarthestInDirection( range: UITextRange, farthestInDirection: UITextLayoutDirection - ): UITextPosition? = null // TODO positionWithinRange + ): UITextPosition { + return IntermediateTextPosition(0) + } override fun characterRangeByExtendingPosition( position: UITextPosition, @@ -406,28 +522,100 @@ internal class IntermediateTextInputUIView( writingDirection: NSWritingDirection, forRange: UITextRange ) { - // TODO support RTL text direction + // TODO: Verify if no more handling needed } // Working with Geometry and Hit-Testing. Some methods return stubs for now. - override fun firstRectForRange(range: UITextRange): CValue = CGRectNull.readValue() + override fun firstRectForRange(range: UITextRange): CValue { + if (usingNITI) { + return input?.firstSelectionRectForRange(range.toTextRange())?.asCGRect() + ?: CGRectZero.readValue() + } else { + return CGRectNull.readValue() + } + + } + override fun caretRectForPosition(position: UITextPosition): CValue { - /* TODO: https://youtrack.jetbrains.com/issue/COMPOSE-332/ - CGRectNull here led to crash with Speech-to-text on iOS 16.0 - Set all fields to 1.0 to avoid potential dividing by zero - Ideally, here should be correct rect for caret from Compose. - */ - return CGRectMake(x = 1.0, y = 1.0, width = 1.0, height = 1.0) + val fallbackRect = CGRectMake(x = 1.0, y = 1.0, width = 0.0, height = 1.0) + if (usingNITI) { + // Cursor is drawing on Compose canvas, hence no need to display it in UIKit. + // Returning zero-width rect that will hide cursor on iOS 13 - iOS 16. + // On iOS 17+ cursor is removed manually after it is placed. + + mainScope.launch { + hideCursorView() + } + + val position = (position as? IntermediateTextPosition)?.position ?: return fallbackRect + val caretDpRect = input?.caretDpRectForPosition(position) + return caretDpRect?.asCGRect() ?: fallbackRect + } else { + return fallbackRect + } + } + + override fun selectionRectsForRange(range: UITextRange): List<*> { + if (usingNITI) { + val fallbackList = listOf() // can't be empty? + val textRange = TextRange( + start = (range.start as? IntermediateTextPosition)?.position ?: return fallbackList, + end = (range.end as? IntermediateTextPosition)?.position ?: return fallbackList + ) + val rects = input?.selectionDpRectsForRange(textRange) ?: return fallbackList + + // HACK: On iOS 17+, selection changes are not submitted during selection interaction. + if (available(OS.Ios to OSVersion(major = 17)) && + touchesTrackerGestureRecognizer.isTrackingTouches + ) { + shouldPerformSelectionNotifications = false + if (input?.getSelectedTextRange() != textRange) { + input?.setSelectedTextRange(textRange) + } + shouldPerformSelectionNotifications = true + } + + return rects.fastMap { IntermediateTextSelectionRect(it) } + } else { + return listOf() + } + } + + override fun closestPositionToPoint(point: CValue): UITextPosition? { + if (usingNITI) { + val closestPosition = + input?.closestPositionToPoint(point.useContents { DpOffset(x.dp, y.dp) }) ?: return null + return IntermediateTextPosition(closestPosition) + } else { + return null + } } - override fun selectionRectsForRange(range: UITextRange): List<*> = listOf() - override fun closestPositionToPoint(point: CValue): UITextPosition? = null override fun closestPositionToPoint( point: CValue, withinRange: UITextRange - ): UITextPosition? = null + ): UITextPosition? { + if (usingNITI) { + val textRange = (withinRange as? IntermediateTextRange)?.toTextRange() ?: return null + val closestPosition = input?.closestPositionToPoint( + point.useContents { DpOffset(x.dp, y.dp) }, + textRange + ) ?: return null + return IntermediateTextPosition(closestPosition) + } else { + return null + } + } - override fun characterRangeAtPoint(point: CValue): UITextRange? = null + override fun characterRangeAtPoint(point: CValue): UITextRange? { + if (usingNITI) { + val characterRange = + input?.characterRangeAtPoint(point.useContents { DpOffset(x.dp, y.dp) }) ?: return null + return IntermediateTextRange(characterRange.start, characterRange.end) + } else { + return null + } + } override fun textStylingAtPosition( position: UITextPosition, @@ -489,21 +677,27 @@ internal class IntermediateTextInputUIView( _inputDelegate?.textDidChange(this) } + private var shouldPerformSelectionNotifications: Boolean = usingNITI + /** * Call when something changes in text data */ fun selectionWillChange() { - _inputDelegate?.selectionWillChange(this) + if (shouldPerformSelectionNotifications) { + _inputDelegate?.selectionWillChange(this) + } } /** * Call when something changes in text data */ fun selectionDidChange() { - _inputDelegate?.selectionDidChange(this) + if (shouldPerformSelectionNotifications) { + _inputDelegate?.selectionDidChange(this) + } } - override fun isUserInteractionEnabled(): Boolean = false // disable clicks + override fun isUserInteractionEnabled(): Boolean = usingNITI override fun editMenuDelay(): Double = doubleTapTimeoutMillis.milliseconds.toDouble(DurationUnit.SECONDS) @@ -527,8 +721,106 @@ internal class IntermediateTextInputUIView( fun isTextMenuShown() = isEditMenuShown - override fun tokenizer(): UITextInputTokenizerProtocol = - UITextInputStringTokenizer(textInput = this) + private var onCopy: (() -> Unit)? = null + private var onPaste: (() -> Unit)? = null + private var onCut: (() -> Unit)? = null + private var onSelectAll: (() -> Unit)? = null + private var customActions: List = emptyList() + + override fun copy(sender: Any?) { + if (usingNITI) { + onCopy?.invoke() + } else { + super.copy(sender) + } + } + + override fun paste(sender: Any?) { + if (usingNITI) { + onPaste?.invoke() + } else { + super.paste(sender) + } + } + + override fun cut(sender: Any?) { + if (usingNITI) { + onCut?.invoke() + } else { + super.cut(sender) + } + } + + override fun selectAll(sender: Any?) { + if (usingNITI) { + onSelectAll?.invoke() + } else { + super.selectAll(sender) + } + } + + override fun canPerformAction(action: COpaquePointer?, withSender: Any?): Boolean { + if (usingNITI) { + val selectorName = NSStringFromSelector(action) + + return when (selectorName) { + "copy:" -> onCopy != null + "paste:" -> onPaste != null + "cut:" -> onCut != null + "selectAll:" -> onSelectAll != null + else -> super.canPerformAction(action, withSender) + } + } else { + return super.canPerformAction(action, withSender) + } + } + + fun updateMenuActions( + copy: (() -> Unit)?, + paste: (() -> Unit)?, + cut: (() -> Unit)?, + selectAll: (() -> Unit)?, + customActions: List + ) { + onCopy = copy + onPaste = paste + onCut = cut + onSelectAll = selectAll + this.customActions = customActions + } + + @Suppress("UNCHECKED_CAST") + override fun editMenuForTextRange(textRange: UITextRange, suggestedActions: List<*>): UIMenu? { + if (usingNITI) { + val customMenuElements = makeCustomMenuElements() + if (customMenuElements.isEmpty()) return null // The default menu would be returned + + val suggestedActionsElements = suggestedActions as List + + return UIMenu.menuWithTitle("", children = customMenuElements + suggestedActionsElements) + } else { + return null + } + } + + private fun makeCustomMenuElements(): List { + if (customActions.isEmpty()) return emptyList() + return customActions.mapNotNull { + val title = it.title ?: return@mapNotNull null + val block = it.actionBlock ?: return@mapNotNull null + UIAction.actionWithTitle( + title = title, + image = null, + identifier = null, + handler = { block() }, + ) + } + } + + private val _tokenizer = IntermediateTextTokenizer(textInput = this) { + input?.let { it.textInRange(TextRange(0, it.endOfDocument())) } + } + override fun tokenizer(): UITextInputTokenizerProtocol = _tokenizer fun resetOnKeyboardPressesCallback() { onKeyboardPresses = NoOpOnKeyboardPresses @@ -541,6 +833,22 @@ internal class IntermediateTextInputUIView( endEditBatch() } } + + private fun hideCursorView() { + // TODO Revert commenting +// val cursorViewClass = when { +// available(OS.Ios to OSVersion(major = 17, minor = 4)) -> "UIStandardTextCursorView" +// available(OS.Ios to OSVersion(major = 17)) -> "_UITextCursorView" +// else -> return +// } +// +// subviews.forEach { subview -> +// subview as UIView +// if (subview::class.simpleName == cursorViewClass) { +// subview.setHidden(true) +// } +// } + } } private class IntermediateTextPosition(val position: Int = 0) : UITextPosition() { @@ -553,6 +861,29 @@ private class IntermediateTextPosition(val position: Int = 0) : UITextPosition() } } +private class IntermediateTextSelectionRect( + private var _rect: CValue, + private val _writingDirection: UITextWritingDirection, + private val _containsStart: Boolean, + private val _containsEnd: Boolean, + private val _isVertical: Boolean + +) : UITextSelectionRect() { + constructor(textSelectionRect: TextSelectionRect) : this( + textSelectionRect.dpRect.asCGRect(), + NSWritingDirectionNatural, + textSelectionRect.containsStart, + textSelectionRect.containsEnd, + textSelectionRect.isVertical + ) + + override fun rect(): CValue = _rect + override fun writingDirection(): NSWritingDirection = _writingDirection + override fun containsStart(): Boolean = _containsStart + override fun containsEnd(): Boolean = _containsEnd + override fun isVertical(): Boolean = _isVertical +} + private fun IntermediateTextRange(start: Int, end: Int) = IntermediateTextRange( _start = IntermediateTextPosition(start), @@ -580,3 +911,282 @@ private fun UITextRange.toTextRange(): TextRange { private fun TextRange.toUITextRange(): UITextRange = IntermediateTextRange(start = start, end = end) + +internal class IntermediateTextTokenizer( + textInput: UIResponder, + val getString: () -> String? +): CMPTextInputStringTokenizer(textInput) { + override fun positionFromPosition( + position: UITextPosition, + toBoundary: UITextGranularity, + inDirection: UITextDirection + ): UITextPosition? { + val textPosition = position as? IntermediateTextPosition ?: return null + val isForward = inDirection == UITextStorageDirectionForward || + inDirection == UITextLayoutDirectionRight || + inDirection == UITextLayoutDirectionDown + + val iterator = when (toBoundary) { + UITextGranularity.UITextGranularityCharacter -> BreakIterator.makeCharacterInstance() + UITextGranularity.UITextGranularityWord -> BreakIterator.makeWordInstance() + UITextGranularity.UITextGranularitySentence -> BreakIterator.makeSentenceInstance() + UITextGranularity.UITextGranularityLine -> BreakIterator.makeLineInstance() + UITextGranularity.UITextGranularityParagraph -> + return positionFromPositionToParagraphBoundary(position, isForward) + + else -> return super.positionFromPosition(position, toBoundary, inDirection) + } + + val string = getString() ?: "" + iterator.setText(string) + + val iteratorResult = if (isForward) { + if (textPosition.position >= string.length - 1) { + string.length + } else { + iterator.following(textPosition.position) + } + } else { + if (textPosition.position <= 0) { + 0 + } else { + iterator.preceding(textPosition.position) + } + } + + return IntermediateTextPosition(iteratorResult) + } + + override fun isPositionAtBoundary( + position: UITextPosition, + atBoundary: UITextGranularity, + inDirection: UITextDirection + ): Boolean { + val textPosition = position as? IntermediateTextPosition ?: return false + + val iterator = when (atBoundary) { + UITextGranularity.UITextGranularityCharacter -> BreakIterator.makeCharacterInstance() + UITextGranularity.UITextGranularityWord -> BreakIterator.makeWordInstance() + UITextGranularity.UITextGranularitySentence -> BreakIterator.makeSentenceInstance() + UITextGranularity.UITextGranularityLine -> BreakIterator.makeLineInstance() + UITextGranularity.UITextGranularityParagraph -> + // TODO: Properly implement Paragraph boundary check, or write comment why false value is optimal one here. + return false + + else -> return super.isPositionAtBoundary(position, atBoundary, inDirection) + } + + iterator.setText(getString() ?: "") + return iterator.isBoundary(textPosition.position) + } + + private fun positionFromPositionToParagraphBoundary( + position: UITextPosition, + isForward: Boolean + ): UITextPosition? { + val textPosition = position as? IntermediateTextPosition ?: return null + val newlineCharacters: Set = setOf('\n', '\r', '\u2029') + + val string = getString() ?: "" + var location = textPosition.position + while (isForward && location < string.length || !isForward && location > 0) { + if (isForward) { + if (string[location] in newlineCharacters) { + break + } + location++ + } else { + if (string[location] in newlineCharacters) { + location++ + break + } + location-- + } + } + return IntermediateTextPosition(location) + } +} + +internal class IntermediateTextScrollView(): UIScrollView(frame = CGRectZero.readValue()) { + init { + setScrollEnabled(false) + setShowsVerticalScrollIndicator(false) + setShowsHorizontalScrollIndicator(false) + setCanCancelContentTouches(false) + setDelaysContentTouches(false) + setClipsToBounds(true) + } + + var textView: IntermediateTextInputUIView? = null + set(value) { + if (field != value) { + field?.removeFromSuperview() + field = value + value?.let { + addSubview(value) + } + } + } + + override fun hitTest(point: CValue, withEvent: UIEvent?): UIView? { + val textView = textView ?: return null + val hitTestResult = super.hitTest(point, withEvent) + + return if (available(OS.Ios to OSVersion(major = 17))) { + (hitTestResult ?: hitTestTextInteractiveViews( + point = point, + excludeItemsWithBounds = textView.bounds + ))?.let { + // The text input view always returns self as a hit test result, regardless of whether + // the pointer hits interactive elements within the view, such as selection handles. + textView + } + } else { + // On iOS <= 16 actual text interaction view is zero size. + // Find it using hit testing over subviews. + hitTestResult ?: textView.subviews.firstNotNullOfOrNull { subview -> + subview as UIView + val subviewPoint = this.convertPoint(point, toView = subview) + subview.hitTest(subviewPoint, withEvent = withEvent) + } + } + } + + fun setFrame(dpNewFrame: DpRect, dpTextBounds: DpRect, dpInsets: PlatformInsets) { + val newFrame = dpNewFrame.asCGRect() + val textBounds = dpTextBounds.asCGRect() + + val textViewFrame = CGRectMake( + x = 0.0, + y = 0.0, + width = CGRectGetWidth(textBounds), + height = CGRectGetHeight(textBounds) + ) + + val insets = UIEdgeInsetsMake( + top = dpInsets.top.toDouble(), + left = dpInsets.left.toDouble(), + bottom = dpInsets.bottom.toDouble(), + right = dpInsets.right.toDouble() + ) + + val scrollContentSize = textBounds.useContents { size.readValue() } + val scrollContentInset = textBounds.useContents { origin.readValue() } + + val textFrameChanged = + textView?.let { !CGRectEqualToRect(it.frame, textViewFrame) } ?: false + val frameChanged = !CGRectEqualToRect(frame, newFrame) + val contentInsetChanged = !UIEdgeInsetsEqualToEdgeInsets(contentInset, insets) + val contentSizeChanged = !CGSizeEqualToSize(contentSize, scrollContentSize) + val contentOffsetChanged = !CGPointEqualToPoint(contentOffset, scrollContentInset) + + val hasChanges = textFrameChanged || + frameChanged || + contentInsetChanged || + contentSizeChanged || + contentOffsetChanged + + if (hasChanges) { + textView?.selectionWillChange() + + textView?.setFrame(textViewFrame) + setFrame(newFrame) + setContentInset(insets) + setContentSize(scrollContentSize) + setContentOffset(scrollContentInset) + + textView?.selectionDidChange() + } + } + + fun interactionModeAt(point: CValue): UIKitInteropInteractionMode? { + val selectionHandleOrCursor = hitTestTextInteractiveViews( + point = point, + excludeItemsWithBounds = textView?.bounds ?: CGRectZero.readValue() + ) + + return if (selectionHandleOrCursor != null && selectionHandleOrCursor != this) { + UIKitInteropInteractionMode.NonCooperative + } else if (CGRectContainsPoint(bounds, point)) { + UIKitInteropInteractionMode.Cooperative(1000) + } else { + null + } + } +} + +/** + * The method traverses the text input view hierarchy, looking for interactive elements such as + * selection handles or the cursor: usually these are only views that have different boundaries + * from the text input view. + * The method used to test interactive text editing elements when they are outside the boundaries + * of the text input view. + */ +private fun UIView.hitTestTextInteractiveViews( + point: CValue, + excludeItemsWithBounds: CValue, + level: Int = 0 +): UIView? { + subviews.reversed().forEach { subview -> + subview as UIView + val subviewPoint = this.convertPoint(point, toView = subview) + subview.hitTestTextInteractiveViews(subviewPoint, excludeItemsWithBounds, level + 1)?.let { + return it + } + } + return this.takeIf { + !CGRectEqualToRect(bounds, excludeItemsWithBounds) && + CGRectContainsPoint(CGRectInset(bounds, -4.0, -4.0), point) + } +} + +private class TouchTrackingGestureRecognizer : CMPGestureRecognizer(target = null, action = null) { + private val trackedTouches = mutableSetOf() + + val isTrackingTouches: Boolean get() = trackedTouches.isNotEmpty() + + init { + cancelsTouchesInView = true + delaysTouchesBegan = false + } + + override fun touchesBegan(touches: Set<*>, withEvent: UIEvent) { + super.touchesBegan(touches, withEvent) + + touches.forEach { + it as UITouch + trackedTouches.add(it) + } + } + + override fun touchesEnded(touches: Set<*>, withEvent: UIEvent) { + super.touchesEnded(touches, withEvent) + + touches.forEach { + it as UITouch + trackedTouches.remove(it) + } + } + + override fun touchesCancelled(touches: Set<*>, withEvent: UIEvent) { + super.touchesCancelled(touches, withEvent) + + touches.forEach { + it as UITouch + trackedTouches.remove(it) + } + } + + override fun canBePreventedByGestureRecognizer( + preventingGestureRecognizer: UIGestureRecognizer + ): Boolean { + return false + } + + override fun canPreventGestureRecognizer( + preventedGestureRecognizer: UIGestureRecognizer + ): Boolean { + // Prevent other gesture recognizers so this one handles touches exclusively + return true + } +} \ No newline at end of file From d5993cd56bf84813fdc9e31fdd942d3df03ad325 Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Thu, 27 Nov 2025 18:40:05 +0400 Subject: [PATCH 02/14] xcodeproj fixes --- .../CMPUIKitUtils.xcodeproj/project.pbxproj | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj index a8608fa8d34e6..0a96e703c31be 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj @@ -25,6 +25,9 @@ 99CC4B322ECE16C8007C5C44 /* CMPViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99CC4B312ECE16C8007C5C44 /* CMPViewTests.swift */; }; 99D97A882BF73A9B0035552B /* CMPEditMenuView.m in Sources */ = {isa = PBXBuildFile; fileRef = 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */; }; 99DCAB0E2BD00F5C002E6AC7 /* CMPTextLoupeSession.m in Sources */ = {isa = PBXBuildFile; fileRef = 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */; }; + C4A8C3CC2ED89773002A1848 /* CMPEditMenuCustomAction.m in Sources */ = {isa = PBXBuildFile; fileRef = C4A8C3C72ED89773002A1848 /* CMPEditMenuCustomAction.m */; }; + C4A8C3CD2ED89773002A1848 /* CMPTextInputView.m in Sources */ = {isa = PBXBuildFile; fileRef = C4A8C3CB2ED89773002A1848 /* CMPTextInputView.m */; }; + C4A8C3CE2ED89773002A1848 /* CMPTextInputStringTokenizer.m in Sources */ = {isa = PBXBuildFile; fileRef = C4A8C3C92ED89773002A1848 /* CMPTextInputStringTokenizer.m */; }; EA4B52962C2EDEF200FBB55C /* CMPGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = EA4B52952C2EDEF200FBB55C /* CMPGestureRecognizer.m */; }; EA70A7EB2B27106100300068 /* CMPAccessibilityElement.m in Sources */ = {isa = PBXBuildFile; fileRef = EA70A7E82B27106100300068 /* CMPAccessibilityElement.m */; }; EA82F4F92B86144E00465418 /* CMPOSLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = EA82F4F82B86144E00465418 /* CMPOSLogger.m */; }; @@ -100,6 +103,12 @@ 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPEditMenuView.m; sourceTree = ""; }; 99DCAB0C2BD00F5C002E6AC7 /* CMPTextLoupeSession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPTextLoupeSession.h; sourceTree = ""; }; 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPTextLoupeSession.m; sourceTree = ""; }; + C4A8C3C62ED89773002A1848 /* CMPEditMenuCustomAction.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPEditMenuCustomAction.h; sourceTree = ""; }; + C4A8C3C72ED89773002A1848 /* CMPEditMenuCustomAction.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPEditMenuCustomAction.m; sourceTree = ""; }; + C4A8C3C82ED89773002A1848 /* CMPTextInputStringTokenizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPTextInputStringTokenizer.h; sourceTree = ""; }; + C4A8C3C92ED89773002A1848 /* CMPTextInputStringTokenizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPTextInputStringTokenizer.m; sourceTree = ""; }; + C4A8C3CA2ED89773002A1848 /* CMPTextInputView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPTextInputView.h; sourceTree = ""; }; + C4A8C3CB2ED89773002A1848 /* CMPTextInputView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPTextInputView.m; sourceTree = ""; }; EA4B52942C2EDEF200FBB55C /* CMPGestureRecognizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPGestureRecognizer.h; sourceTree = ""; }; EA4B52952C2EDEF200FBB55C /* CMPGestureRecognizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPGestureRecognizer.m; sourceTree = ""; }; EA70A7E62B27106100300068 /* CMPAccessibilityElement.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CMPAccessibilityElement.h; sourceTree = ""; }; @@ -149,6 +158,12 @@ 996EFEEB2B02CE5D0000FE0F /* CMPUIKitUtils */ = { isa = PBXGroup; children = ( + C4A8C3C62ED89773002A1848 /* CMPEditMenuCustomAction.h */, + C4A8C3C72ED89773002A1848 /* CMPEditMenuCustomAction.m */, + C4A8C3C82ED89773002A1848 /* CMPTextInputStringTokenizer.h */, + C4A8C3C92ED89773002A1848 /* CMPTextInputStringTokenizer.m */, + C4A8C3CA2ED89773002A1848 /* CMPTextInputView.h */, + C4A8C3CB2ED89773002A1848 /* CMPTextInputView.m */, 99CC4B282ECE04AC007C5C44 /* CMPComposeContainerLifecycleDelegate.h */, 99CC4B2B2ECE07EA007C5C44 /* CMPComposeContainerLifecycleState.h */, EA70A7E62B27106100300068 /* CMPAccessibilityElement.h */, @@ -372,6 +387,9 @@ EABD912B2BC02B5F00455279 /* CMPInteropWrappingView.m in Sources */, EADD02902C9846D9003F66E8 /* CMPDragInteractionProxy.m in Sources */, 992EDDFB2E55EC8400FB44C5 /* CMPKeyValueObserver.m in Sources */, + C4A8C3CC2ED89773002A1848 /* CMPEditMenuCustomAction.m in Sources */, + C4A8C3CD2ED89773002A1848 /* CMPTextInputView.m in Sources */, + C4A8C3CE2ED89773002A1848 /* CMPTextInputStringTokenizer.m in Sources */, EA82F4F92B86144E00465418 /* CMPOSLogger.m in Sources */, 9968C3612D7746BD005E8DE4 /* CMPHoverGestureHandler.m in Sources */, EA4B52962C2EDEF200FBB55C /* CMPGestureRecognizer.m in Sources */, From 04b97d0b5652380aea776eb38e8b6d85e4a28ead Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Thu, 27 Nov 2025 18:49:49 +0400 Subject: [PATCH 03/14] reverting unnecessary changes --- .../androidx/compose/foundation/ComposeFoundationFlags.kt | 1 - .../kotlin/androidx/compose/foundation/text/CoreTextField.kt | 2 +- .../foundation/text/CupertinoTextFieldPointerModifier.skiko.kt | 2 +- .../compose/foundation/cupertino/CupertinoOverscrollEffect.kt | 2 +- .../input/internal/selection/TextFieldSelectionState.uikit.kt | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt index b93c2d2cbd61a..f28b1f4c995a7 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt @@ -68,7 +68,6 @@ object ComposeFoundationFlags { * [BasicTextField][androidx.compose.foundation.text.BasicTextField]s. If false, the previous * context menu that has no public APIs will be used instead. */ - // TODO mazunin-v: don't forget to revert it @Suppress("MutableBareField") @JvmField var isNewContextMenuEnabled = false /** diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt index 77fb1b4b6bfbb..69d5247699b6f 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt @@ -659,7 +659,7 @@ internal fun CoreTextField( if ( state.handleState == HandleState.Cursor && !readOnly && showHandleAndMagnifier ) { -// TextFieldCursorHandle(manager = manager) + TextFieldCursorHandle(manager = manager) } } } diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt index 5ea58151d40e3..90ef1744f6347 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt @@ -343,4 +343,4 @@ private fun createTextFieldValue( annotatedString = annotatedString, selection = selection ) -} \ No newline at end of file +} diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt index 1988186b0bcf0..497f47a8544d7 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt @@ -480,7 +480,7 @@ private class CupertinoOverscrollNode( pointersDown-- } } -// assert(pointersDown >= 0) { "pointersDown cannot be negative" } // TODO Overscroll in NITI shouldn't be fixed like that + assert(pointersDown >= 0) { "pointersDown cannot be negative" } } } diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt index 98517fb1fc8db..91ad3a3ae55e6 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt @@ -418,4 +418,4 @@ internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( textFieldItem(TextContextMenuKeys.SelectAllKey, enabled = canShowSelectAllMenuItem()) { selectAll() } separator() } -} \ No newline at end of file +} From 23d386542b9ffa75132df875493a00d19fcabad3 Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Fri, 28 Nov 2025 14:29:02 +0400 Subject: [PATCH 04/14] fixed CMPUtils header --- .../uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h index 62ede85d55fce..edfb9926820a7 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h @@ -37,5 +37,5 @@ FOUNDATION_EXPORT const unsigned char CMPUIKitUtilsVersionString[]; #import "CMPTextInputStringTokenizer.h" #import "CMPScrollView.h" #import "CMPComposeContainerLifecycleDelegate.h" -#import "CMPTextInputView.h" #import "CMPEditMenuCustomAction.h" +#import "CMPEditMenuView.h" \ No newline at end of file From dd35b4e06e580a97db73f83eac7c87ed62f84f9e Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Fri, 28 Nov 2025 14:52:49 +0400 Subject: [PATCH 05/14] remove unnecessary macos actuals, added one universal macos actual --- compose/mpp/demo/build.gradle.kts | 2 ++ .../textfield/PlatformImeHelpers.macos.kt} | 2 +- .../textfield/PlatformImeHelpers.macosX64.kt | 25 ------------------- 3 files changed, 3 insertions(+), 26 deletions(-) rename compose/mpp/demo/src/{macosArm64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosArm64.kt => macosMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macos.kt} (99%) delete mode 100644 compose/mpp/demo/src/macosX64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosX64.kt diff --git a/compose/mpp/demo/build.gradle.kts b/compose/mpp/demo/build.gradle.kts index 59b8a8b95831d..47168362be054 100644 --- a/compose/mpp/demo/build.gradle.kts +++ b/compose/mpp/demo/build.gradle.kts @@ -281,6 +281,8 @@ if (System.getProperty("os.name") == "Mac OS X") { throw IllegalStateException("Please run the task from Xcode") } } else { + kotlinBinary.debuggable = true + kotlinBinary.optimized = false // Otherwise copy the executable into the Xcode output directory. tasks.create("packForXCode", Copy::class.java) { dependsOn(kotlinBinary.linkTaskProvider) diff --git a/compose/mpp/demo/src/macosArm64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosArm64.kt b/compose/mpp/demo/src/macosMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macos.kt similarity index 99% rename from compose/mpp/demo/src/macosArm64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosArm64.kt rename to compose/mpp/demo/src/macosMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macos.kt index e704dd4e448f1..5a9cf1441cf57 100644 --- a/compose/mpp/demo/src/macosArm64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosArm64.kt +++ b/compose/mpp/demo/src/macosMain/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macos.kt @@ -22,4 +22,4 @@ import androidx.compose.ui.ExperimentalComposeUiApi @ExperimentalComposeUiApi actual fun nativeKeyboardOptionsUseNativeInputHandling(enabled: Boolean): KeyboardOptions { return KeyboardOptions() -} +} \ No newline at end of file diff --git a/compose/mpp/demo/src/macosX64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosX64.kt b/compose/mpp/demo/src/macosX64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosX64.kt deleted file mode 100644 index e704dd4e448f1..0000000000000 --- a/compose/mpp/demo/src/macosX64Main/kotlin/androidx/compose/mpp/demo/textfield/PlatformImeHelpers.macosX64.kt +++ /dev/null @@ -1,25 +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.mpp.demo.textfield - -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.ExperimentalComposeUiApi - -@ExperimentalComposeUiApi -actual fun nativeKeyboardOptionsUseNativeInputHandling(enabled: Boolean): KeyboardOptions { - return KeyboardOptions() -} From d93cc661acdb3788a49e4b0268dd01ab846370a2 Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Fri, 28 Nov 2025 14:54:23 +0400 Subject: [PATCH 06/14] reverted accidental commit of debug flags --- compose/mpp/demo/build.gradle.kts | 2 -- 1 file changed, 2 deletions(-) diff --git a/compose/mpp/demo/build.gradle.kts b/compose/mpp/demo/build.gradle.kts index 47168362be054..59b8a8b95831d 100644 --- a/compose/mpp/demo/build.gradle.kts +++ b/compose/mpp/demo/build.gradle.kts @@ -281,8 +281,6 @@ if (System.getProperty("os.name") == "Mac OS X") { throw IllegalStateException("Please run the task from Xcode") } } else { - kotlinBinary.debuggable = true - kotlinBinary.optimized = false // Otherwise copy the executable into the Xcode output directory. tasks.create("packForXCode", Copy::class.java) { dependsOn(kotlinBinary.linkTaskProvider) From 3bb0be723cbfed6b6c00037af7d1ca0b97b4e9e1 Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Fri, 28 Nov 2025 15:18:10 +0400 Subject: [PATCH 07/14] reverted observeSelectionChanges in commonMain part of BTF2, which seem unnecessary --- .../selection/TextFieldSelectionState.kt | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt index 83f4259671610..d63dea197046a 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.kt @@ -536,7 +536,6 @@ internal class TextFieldSelectionState( try { coroutineScope { launch { observeTextChanges() } - launch { observeSelectionChanges() } launch { observeTextToolbarVisibility() } } } finally { @@ -547,23 +546,6 @@ internal class TextFieldSelectionState( } } - private suspend fun observeSelectionChanges() { - snapshotFlow { - val isCollapsed = textFieldState.visualText.selection.collapsed - if (draggingHandle == null && isInTouchMode) { - if (isCollapsed) { - Cursor - } else { - Selection - } - } else { - None - } - }.collect { state -> - updateTextToolbarState(state) - } - } - fun updateTextToolbarState(textToolbarState: TextToolbarState) { this.textToolbarState = textToolbarState } From e9c2ed265e304784845c90954b03a39154aa9eff Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Fri, 28 Nov 2025 15:39:20 +0400 Subject: [PATCH 08/14] removed unnecessary imports from IntermediateTextInputUIView.uikit.kt, removed unnecessary method from IOSSkikoInput.uikit.kt --- .../ui/platform/IOSSkikoInput.uikit.kt | 76 ++++++++++++++++++- .../platform/UIKitTextInputService.uikit.kt | 2 - .../IntermediateTextInputUIView.uikit.kt | 8 -- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt index c9adcf5b0a27a..553f85618d1bc 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt @@ -136,27 +136,99 @@ internal interface IOSSkikoInput { */ fun verticalPositionFromPosition(position: Int, verticalOffset: Int): Int? - fun currentFocusedDpRect(): DpRect? - + /** + * Returns the caret (insertion point) rectangle for a given text position. + * https://developer.apple.com/documentation/uikit/uitextinput/1614490-caretrectforposition + * @param position A text position within the document. + * @return A rectangle, in dp, that encloses the caret at the specified position, or `null` + * if the position is invalid. + */ fun caretDpRectForPosition(position: Int): DpRect? + /** + * Returns the selection rectangles that enclose a range of text. + * https://developer.apple.com/documentation/uikit/uitextinput/1614556-selectionrects + * @param range A range of text in the document. + * @return A list of rectangles, in dp, that tightly bound the visual selection for the range. + */ fun selectionDpRectsForRange(range: TextRange): List + /** + * Returns the first rectangle that encloses a range of text. + * Mirrors UIKit's `firstRectForRange` behavior. + * https://developer.apple.com/documentation/uikit/uitextinput/1649691-firstrectforrange + * @param range A range of text in the document. + * @return The first selection rectangle, in dp, or `null` if the range is invalid or empty. + */ fun firstSelectionRectForRange(range: TextRange): DpRect? + /** + * Returns the text position that is closest to the specified point. + * https://developer.apple.com/documentation/uikit/uitextinput/1614480-closestposition + * @param point A point, in dp, in the coordinate space of the text input. + * @return The position closest to the point, or `null` if none can be determined. + */ fun closestPositionToPoint(point: DpOffset): Int? + /** + * Returns the text position that is closest to the specified point, constrained to a range. + * https://developer.apple.com/documentation/uikit/uitextinput/1614516-closestpositionwithinrange + * @param point A point, in dp, in the coordinate space of the text input. + * @param withinRange A range that limits the returned position. + * @return The closest position within the given range, or `null` if none exists. + */ fun closestPositionToPoint(point: DpOffset, withinRange: TextRange): Int? + /** + * Returns the character range at the specified point. + * https://developer.apple.com/documentation/uikit/uitextinput/1614471-characterrange + * @param point A point, in dp, in the coordinate space of the text input. + * @return The range of the character at the point, or `null` if none. + */ fun characterRangeAtPoint(point: DpOffset): TextRange? + /** + * Returns a text position located at a character offset within a range. + * https://developer.apple.com/documentation/uikit/uitextinput/1614571-positionwithinrange + * @param range The containing range. + * @param atCharacterOffset A zero-based offset, in characters, from the start of the range. + * @return The resulting position, or `null` if the offset is out of bounds. + */ fun positionWithinRange(range: TextRange, atCharacterOffset: Int): Int? + /** + * Returns the position in a specified direction that is farthest within a given range. + * https://developer.apple.com/documentation/uikit/uitextinput/1614551-positionwithinrange + * @param range The limiting range. + * @param farthestIndirection A direction constant (e.g., forward/backward/left/right). + * @return The farthest position within the range in the given direction, or `null` if none. + */ fun positionWithinRange(range: TextRange, farthestIndirection: String): Int? + /** + * Returns the range that extends from a position in a given direction to encompass a character unit. + * https://developer.apple.com/documentation/uikit/uitextinput/1614559-characterrangebyextendingposition + * @param position The starting text position. + * @param direction A direction constant indicating how to extend (e.g., forward/backward). + * @return The extended character range, or `null` if the position is invalid. + */ fun characterRangeByExtendingPosition(position: Int, direction: String): TextRange? + /** + * Returns the base writing direction for text at a position moving in a specified direction. + * https://developer.apple.com/documentation/uikit/uitextinput/1614550-basewritingdirectionforposition + * @param position The reference text position. + * @param inDirection A direction constant that indicates the movement direction. + * @return The base writing direction (e.g., LTR or RTL) as a string, or `null` if unknown. + */ fun baseWritingDirectionForPosition(position: Int, inDirection: String): String? + /** + * Returns the number of characters between two positions. + * https://developer.apple.com/documentation/uikit/uitextinput/1614565-offsetfromposition + * @param fromPosition The starting position. + * @param toPosition The ending position. + * @return A positive, negative, or zero value indicating the distance in characters. + */ fun offset(fromPosition: Int, toPosition: Int): Int } \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt index cbca24fb210d8..52ddd199f2932 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt @@ -858,8 +858,6 @@ internal class UIKitTextInputService( } } - override fun currentFocusedDpRect(): DpRect? = currentFocusedRect?.toDpRect(view.density) - override fun caretDpRectForPosition(position: Int): DpRect? { val text = getState()?.text ?: return null if (position < 0 || position > text.length) { diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt index 5255fc84f9e07..f141340eab7aa 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.platform.TextActions import androidx.compose.ui.platform.TextSelectionRect import androidx.compose.ui.text.TextRange import androidx.compose.ui.uikit.utils.CMPEditMenuView -import androidx.compose.ui.uikit.utils.CMPTextInputView import androidx.compose.ui.uikit.utils.CMPEditMenuCustomAction import androidx.compose.ui.uikit.utils.CMPGestureRecognizer import androidx.compose.ui.uikit.utils.CMPTextInputStringTokenizer @@ -58,7 +57,6 @@ import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGRectNull import platform.CoreGraphics.CGRectZero import platform.CoreGraphics.CGSizeEqualToSize -import platform.Foundation.NSArray import platform.Foundation.NSComparisonResult import platform.Foundation.NSDictionary import platform.Foundation.NSOrderedAscending @@ -67,7 +65,6 @@ import platform.Foundation.NSOrderedSame import platform.Foundation.NSRange import platform.Foundation.NSStringFromSelector import platform.Foundation.dictionary -import platform.QuartzCore.CAShapeLayer import platform.UIKit.NSWritingDirection import platform.UIKit.NSWritingDirectionNatural import platform.UIKit.UIAction @@ -79,10 +76,7 @@ import platform.UIKit.UIKeyInputProtocol import platform.UIKit.UIKeyboardAppearance import platform.UIKit.UIKeyboardType import platform.UIKit.UIMenu -import platform.UIKit.UIMenuAutoFill -import platform.UIKit.UIMenuBuilderProtocol import platform.UIKit.UIMenuElement -import platform.UIKit.UIMenuItem import platform.UIKit.UIPress import platform.UIKit.UIPressesEvent import platform.UIKit.UIResponder @@ -112,9 +106,7 @@ import platform.UIKit.UITextWritingDirection import platform.UIKit.UITouch import platform.UIKit.UIView import platform.UIKit.UIWritingToolsBehavior -import platform.UIKit.UIWritingToolsResultOptions import platform.UIKit.addInteraction -import platform.UIKit.removeInteraction import platform.darwin.NSInteger private val NoOpOnKeyboardPresses: (Set<*>) -> Unit = {} From 30e5fe6256d4d2d2fbd575da226efea0a16b1273 Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Fri, 28 Nov 2025 16:05:40 +0400 Subject: [PATCH 09/14] clean up IntermediateTextInputUIView.uikit.kt --- .../compose/ui/platform/TextActions.uikit.kt | 39 ------------ .../IntermediateTextInputUIView.uikit.kt | 62 ++----------------- 2 files changed, 6 insertions(+), 95 deletions(-) delete mode 100644 compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/TextActions.uikit.kt diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/TextActions.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/TextActions.uikit.kt deleted file mode 100644 index f46a34bcf9a13..0000000000000 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/TextActions.uikit.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2023 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 - -internal interface TextActions { - /** - * Copy action. If null, then copy is not possible in current context - */ - val copy: (() -> Unit)? - - /** - * Paste action. If null, then paste is not possible in current context - */ - val paste: (() -> Unit)? - - /** - * Cut action. If null, then cut is not possible in current context - */ - val cut: (() -> Unit)? - - /** - * SelectAll action. If null, then select all is not possible in current context - */ - val selectAll: (() -> Unit)? -} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt index f141340eab7aa..54f8cc6c50caf 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.platform.EmptyInputTraits import androidx.compose.ui.platform.IOSSkikoInput import androidx.compose.ui.platform.PlatformInsets import androidx.compose.ui.platform.SkikoUITextInputTraits -import androidx.compose.ui.platform.TextActions import androidx.compose.ui.platform.TextSelectionRect import androidx.compose.ui.text.TextRange import androidx.compose.ui.uikit.utils.CMPEditMenuView @@ -155,15 +154,6 @@ internal class IntermediateTextInputUIView( it.setTextInput(this) } - - override fun layoutSubviews() { - super.layoutSubviews() - - if (usingNITI) { - hideCursorView() - } - } - private var selectionInteractionAttached: Boolean = false override fun didMoveToWindow() { @@ -289,8 +279,8 @@ internal class IntermediateTextInputUIView( } override fun setSelectedTextRange(selectedTextRange: UITextRange?) { + val range = selectedTextRange?.toTextRange() if (usingNITI) { - val range = selectedTextRange?.toTextRange() if (input?.getSelectedTextRange() != range) { // iOS <= 16 does not update selection handles when selection changes from the keyboard // Posting an extra notification solves this issue @@ -308,7 +298,7 @@ internal class IntermediateTextInputUIView( } } else { input?.withBatch { - input?.setSelectedTextRange(selectedTextRange?.toTextRange()) + input?.setSelectedTextRange(range) } } } @@ -513,9 +503,7 @@ internal class IntermediateTextInputUIView( override fun setBaseWritingDirection( writingDirection: NSWritingDirection, forRange: UITextRange - ) { - // TODO: Verify if no more handling needed - } + ) {} // Working with Geometry and Hit-Testing. Some methods return stubs for now. override fun firstRectForRange(range: UITextRange): CValue { @@ -531,14 +519,6 @@ internal class IntermediateTextInputUIView( override fun caretRectForPosition(position: UITextPosition): CValue { val fallbackRect = CGRectMake(x = 1.0, y = 1.0, width = 0.0, height = 1.0) if (usingNITI) { - // Cursor is drawing on Compose canvas, hence no need to display it in UIKit. - // Returning zero-width rect that will hide cursor on iOS 13 - iOS 16. - // On iOS 17+ cursor is removed manually after it is placed. - - mainScope.launch { - hideCursorView() - } - val position = (position as? IntermediateTextPosition)?.position ?: return fallbackRect val caretDpRect = input?.caretDpRectForPosition(position) return caretDpRect?.asCGRect() ?: fallbackRect @@ -549,7 +529,7 @@ internal class IntermediateTextInputUIView( override fun selectionRectsForRange(range: UITextRange): List<*> { if (usingNITI) { - val fallbackList = listOf() // can't be empty? + val fallbackList = listOf() val textRange = TextRange( start = (range.start as? IntermediateTextPosition)?.position ?: return fallbackList, end = (range.end as? IntermediateTextPosition)?.position ?: return fallbackList @@ -694,21 +674,6 @@ internal class IntermediateTextInputUIView( override fun editMenuDelay(): Double = doubleTapTimeoutMillis.milliseconds.toDouble(DurationUnit.SECONDS) - /** - * Show copy/paste text menu - * @param targetRect - rectangle of selected text area - * @param textActions - available (not null) actions in text menu - */ - fun showTextMenu(targetRect: CValue, textActions: TextActions) { - this.showEditMenuAtRect( - targetRect = targetRect, - copy = textActions.copy, - cut = textActions.cut, - paste = textActions.paste, - selectAll = textActions.selectAll - ) - } - fun hideTextMenu() = this.hideEditMenu() fun isTextMenuShown() = isEditMenuShown @@ -781,12 +746,13 @@ internal class IntermediateTextInputUIView( this.customActions = customActions } - @Suppress("UNCHECKED_CAST") + override fun editMenuForTextRange(textRange: UITextRange, suggestedActions: List<*>): UIMenu? { if (usingNITI) { val customMenuElements = makeCustomMenuElements() if (customMenuElements.isEmpty()) return null // The default menu would be returned + @Suppress("UNCHECKED_CAST") val suggestedActionsElements = suggestedActions as List return UIMenu.menuWithTitle("", children = customMenuElements + suggestedActionsElements) @@ -825,22 +791,6 @@ internal class IntermediateTextInputUIView( endEditBatch() } } - - private fun hideCursorView() { - // TODO Revert commenting -// val cursorViewClass = when { -// available(OS.Ios to OSVersion(major = 17, minor = 4)) -> "UIStandardTextCursorView" -// available(OS.Ios to OSVersion(major = 17)) -> "_UITextCursorView" -// else -> return -// } -// -// subviews.forEach { subview -> -// subview as UIView -// if (subview::class.simpleName == cursorViewClass) { -// subview.setHidden(true) -// } -// } - } } private class IntermediateTextPosition(val position: Int = 0) : UITextPosition() { From 1e020aa8a0e5b1b43f7bd37a5935469b087eaeba Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Mon, 1 Dec 2025 15:15:13 +0400 Subject: [PATCH 10/14] fixed generated comments in IOSSkikoInput.uikit.kt --- .../ui/platform/IOSSkikoInput.uikit.kt | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt index 553f85618d1bc..0b4ff2a1719ad 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/IOSSkikoInput.uikit.kt @@ -137,8 +137,8 @@ internal interface IOSSkikoInput { fun verticalPositionFromPosition(position: Int, verticalOffset: Int): Int? /** - * Returns the caret (insertion point) rectangle for a given text position. - * https://developer.apple.com/documentation/uikit/uitextinput/1614490-caretrectforposition + * Returns the caret rectangle for a given text position. + * https://developer.apple.com/documentation/uikit/uitextinput/caretrect(for:) * @param position A text position within the document. * @return A rectangle, in dp, that encloses the caret at the specified position, or `null` * if the position is invalid. @@ -147,7 +147,7 @@ internal interface IOSSkikoInput { /** * Returns the selection rectangles that enclose a range of text. - * https://developer.apple.com/documentation/uikit/uitextinput/1614556-selectionrects + * https://developer.apple.com/documentation/uikit/uitextinput/selectionrects(for:) * @param range A range of text in the document. * @return A list of rectangles, in dp, that tightly bound the visual selection for the range. */ @@ -155,8 +155,7 @@ internal interface IOSSkikoInput { /** * Returns the first rectangle that encloses a range of text. - * Mirrors UIKit's `firstRectForRange` behavior. - * https://developer.apple.com/documentation/uikit/uitextinput/1649691-firstrectforrange + * https://developer.apple.com/documentation/uikit/uitextinput/firstrect(for:) * @param range A range of text in the document. * @return The first selection rectangle, in dp, or `null` if the range is invalid or empty. */ @@ -164,15 +163,15 @@ internal interface IOSSkikoInput { /** * Returns the text position that is closest to the specified point. - * https://developer.apple.com/documentation/uikit/uitextinput/1614480-closestposition + * https://developer.apple.com/documentation/uikit/uitextinput/closestposition(to:) * @param point A point, in dp, in the coordinate space of the text input. * @return The position closest to the point, or `null` if none can be determined. */ fun closestPositionToPoint(point: DpOffset): Int? /** - * Returns the text position that is closest to the specified point, constrained to a range. - * https://developer.apple.com/documentation/uikit/uitextinput/1614516-closestpositionwithinrange + * Returns the text position that is closest to the specified point within range. + * https://developer.apple.com/documentation/uikit/uitextinput/closestposition(to:within:) * @param point A point, in dp, in the coordinate space of the text input. * @param withinRange A range that limits the returned position. * @return The closest position within the given range, or `null` if none exists. @@ -180,8 +179,8 @@ internal interface IOSSkikoInput { fun closestPositionToPoint(point: DpOffset, withinRange: TextRange): Int? /** - * Returns the character range at the specified point. - * https://developer.apple.com/documentation/uikit/uitextinput/1614471-characterrange + * Returns the character range at the specified dp point. + * https://developer.apple.com/documentation/uikit/uitextinput/characterrange(at:) * @param point A point, in dp, in the coordinate space of the text input. * @return The range of the character at the point, or `null` if none. */ @@ -189,7 +188,7 @@ internal interface IOSSkikoInput { /** * Returns a text position located at a character offset within a range. - * https://developer.apple.com/documentation/uikit/uitextinput/1614571-positionwithinrange + * https://developer.apple.com/documentation/uikit/uitextinput/position(within:atcharacteroffset:) * @param range The containing range. * @param atCharacterOffset A zero-based offset, in characters, from the start of the range. * @return The resulting position, or `null` if the offset is out of bounds. @@ -198,7 +197,7 @@ internal interface IOSSkikoInput { /** * Returns the position in a specified direction that is farthest within a given range. - * https://developer.apple.com/documentation/uikit/uitextinput/1614551-positionwithinrange + * https://developer.apple.com/documentation/uikit/uitextinput/position(within:farthestin:) * @param range The limiting range. * @param farthestIndirection A direction constant (e.g., forward/backward/left/right). * @return The farthest position within the range in the given direction, or `null` if none. @@ -207,7 +206,7 @@ internal interface IOSSkikoInput { /** * Returns the range that extends from a position in a given direction to encompass a character unit. - * https://developer.apple.com/documentation/uikit/uitextinput/1614559-characterrangebyextendingposition + * https://developer.apple.com/documentation/uikit/uitextinput/characterrange(byextending:in:) * @param position The starting text position. * @param direction A direction constant indicating how to extend (e.g., forward/backward). * @return The extended character range, or `null` if the position is invalid. @@ -216,7 +215,7 @@ internal interface IOSSkikoInput { /** * Returns the base writing direction for text at a position moving in a specified direction. - * https://developer.apple.com/documentation/uikit/uitextinput/1614550-basewritingdirectionforposition + * https://developer.apple.com/documentation/uikit/uitextinput/basewritingdirection(for:in:) * @param position The reference text position. * @param inDirection A direction constant that indicates the movement direction. * @return The base writing direction (e.g., LTR or RTL) as a string, or `null` if unknown. @@ -225,7 +224,7 @@ internal interface IOSSkikoInput { /** * Returns the number of characters between two positions. - * https://developer.apple.com/documentation/uikit/uitextinput/1614565-offsetfromposition + * https://developer.apple.com/documentation/uikit/uitextinput/offset(from:to:) * @param fromPosition The starting position. * @param toPosition The ending position. * @return A positive, negative, or zero value indicating the distance in characters. From 769d9abe6173f1366098c3be4f96c3b0c25af3a3 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 2 Dec 2025 19:21:50 +0100 Subject: [PATCH 11/14] Fix text field cursor activation --- .../objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h | 2 ++ .../objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m | 10 ++++++++++ .../ui/window/IntermediateTextInputUIView.uikit.kt | 1 + 3 files changed, 13 insertions(+) diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h index 49d382c54456b..3fabff1eae271 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h @@ -43,4 +43,6 @@ - (UIView *)inputAccessoryView; +- (void)activateTextInputInteractionIfNeeded; + @end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m index 8a9a762e39e72..61df8336d89d3 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m @@ -432,4 +432,14 @@ - (UIMenu *)editMenuInteraction:(UIEditMenuInteraction *)interaction return [UIMenu menuWithTitle:@"" children:allActions]; } +- (void)activateTextInputInteractionIfNeeded { + if (@available(iOS 17, *)) { + for (id interaction in self.interactions) { + if ([interaction isKindOfClass:[UITextSelectionDisplayInteraction class]]) { + [((UITextSelectionDisplayInteraction *)interaction) setActivated:YES]; + } + } + } +} + @end diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt index 54f8cc6c50caf..decaf2009aa45 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt @@ -175,6 +175,7 @@ internal class IntermediateTextInputUIView( if (usingNITI) { if (!isFirstResponder && this.isFirstResponder()) { this.addInteraction(selectionInteraction) + this.activateTextInputInteractionIfNeeded() } } From 4988e36941703831dd360e1c6d51045a1fc96f0e Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Tue, 2 Dec 2025 20:43:49 +0100 Subject: [PATCH 12/14] Fix tint color --- .../CMPUIKitUtils/CMPEditMenuView.h | 2 ++ .../CMPUIKitUtils/CMPEditMenuView.m | 10 ++++++++++ .../ui/platform/UIKitTextInputService.uikit.kt | 18 ++++++++++++------ .../IntermediateTextInputUIView.uikit.kt | 13 +++++++++++++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h index 3fabff1eae271..2605b9c83ff18 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.h @@ -45,4 +45,6 @@ - (void)activateTextInputInteractionIfNeeded; +- (void)deactivateTextInputInteractionIfNeeded; + @end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m index 61df8336d89d3..939bc893b4780 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPEditMenuView.m @@ -442,4 +442,14 @@ - (void)activateTextInputInteractionIfNeeded { } } +- (void)deactivateTextInputInteractionIfNeeded { + if (@available(iOS 17, *)) { + for (id interaction in self.interactions) { + if ([interaction isKindOfClass:[UITextSelectionDisplayInteraction class]]) { + [((UITextSelectionDisplayInteraction *)interaction) setActivated:NO]; + } + } + } +} + @end diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt index 52ddd199f2932..bfe2b86bba5eb 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt @@ -68,7 +68,6 @@ import kotlinx.coroutines.launch import org.jetbrains.skia.BreakIterator import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGRectZero -import platform.UIKit.UIColor import platform.UIKit.UIPress import platform.UIKit.UIView import platform.UIKit.UIViewAutoresizingFlexibleHeight @@ -183,6 +182,8 @@ internal class UIKitTextInputService( detachIntermediateTextInputView() useNativeInputHandling = false + + selectionTintColor = null } override fun showSoftwareKeyboard() { @@ -514,15 +515,19 @@ internal class UIKitTextInputService( override fun usingNativeInput(): Boolean = useNativeInputHandling - override fun updateTintColor(color: Color) { + private var selectionTintColor: Color? = null + private fun setupTintColor() { textUIView?.let { - val uiColor = color.toUIColor() - if (it.tintColor != uiColor) { - it.setTintColor(uiColor) - } + val uiColor = selectionTintColor?.toUIColor() + it.setTintColor(uiColor) } } + override fun updateTintColor(color: Color) { + selectionTintColor = color + setupTintColor() + } + // The Menu appearance is controlled by UIKit. // Return `Hidden` to make Compose always provide a new set of actions when selection changes. override val status: TextToolbarStatus get() = TextToolbarStatus.Hidden @@ -552,6 +557,7 @@ internal class UIKitTextInputService( it.resignFirstResponder() it.becomeFirstResponder() } + setupTintColor() } else { textUIView = IntermediateTextInputUIView( doubleTapTimeoutMillis = viewConfiguration.doubleTapTimeoutMillis, diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt index decaf2009aa45..eeaf6a9720b17 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt @@ -67,6 +67,7 @@ import platform.Foundation.dictionary import platform.UIKit.NSWritingDirection import platform.UIKit.NSWritingDirectionNatural import platform.UIKit.UIAction +import platform.UIKit.UIColor import platform.UIKit.UIEdgeInsetsEqualToEdgeInsets import platform.UIKit.UIEdgeInsetsMake import platform.UIKit.UIEvent @@ -106,6 +107,7 @@ import platform.UIKit.UITouch import platform.UIKit.UIView import platform.UIKit.UIWritingToolsBehavior import platform.UIKit.addInteraction +import platform.UIKit.systemBlueColor import platform.darwin.NSInteger private val NoOpOnKeyboardPresses: (Set<*>) -> Unit = {} @@ -182,6 +184,17 @@ internal class IntermediateTextInputUIView( return result } + override fun setTintColor(tintColor: UIColor?) { + val colorToSet = tintColor ?: UIColor.systemBlueColor + if (super.tintColor != colorToSet) { + if (this.isFirstResponder()) { + this.deactivateTextInputInteractionIfNeeded() + this.activateTextInputInteractionIfNeeded() + } + super.setTintColor(colorToSet) + } + } + override fun resignFirstResponder(): Boolean { input?.onResignFocus() hideTextMenu() From a679a04fae2b310b3a7b1c4ad578c144aae7d675 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 5 Dec 2025 14:39:09 +0100 Subject: [PATCH 13/14] Fix cursor width, fix context menu appearance by cursor tap --- .../foundation/text/CoreTextField.uikit.kt | 1 + .../input/internal/TextFieldCoreModifier.uikit.kt | 2 ++ .../ui/platform/UIKitNativeTextInputContext.kt | 3 +++ .../ui/platform/UIKitTextInputService.uikit.kt | 15 ++++++++++++++- 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/CoreTextField.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/CoreTextField.uikit.kt index 54cba3360fdc6..41fa6015e7719 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/CoreTextField.uikit.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/CoreTextField.uikit.kt @@ -34,6 +34,7 @@ internal actual fun platformShouldDrawTextControls(cursorBrush: Brush, selection ?.takeIf { it != Color.Unspecified } ?: selectionColor nativeInputContext.updateTintColor(controlsColor) + nativeInputContext.updateCursorThickness(DefaultCursorThickness) } return isUsingNativeInput } \ No newline at end of file diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.uikit.kt index 72c6a82bcccae..c3443b44c07b8 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.uikit.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldCoreModifier.uikit.kt @@ -16,6 +16,7 @@ package androidx.compose.foundation.text.input.internal +import androidx.compose.foundation.text.DefaultCursorThickness import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush @@ -57,5 +58,6 @@ internal actual fun CompositionLocalConsumerModifierNode.drawPlatformCursor( ?.value ?.takeIf { it != Color.Unspecified } ?.let { nativeTextInputContext.updateTintColor(it) } + nativeTextInputContext.updateCursorThickness(DefaultCursorThickness) } } \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitNativeTextInputContext.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitNativeTextInputContext.kt index 84f584225f04e..7d6d53c51e813 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitNativeTextInputContext.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitNativeTextInputContext.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.platform import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.uikit.utils.CMPEditMenuCustomAction +import androidx.compose.ui.unit.Dp interface UIKitNativeTextInputContext { fun usingNativeInput(): Boolean @@ -33,4 +34,6 @@ interface UIKitNativeTextInputContext { ) fun updateTintColor(color: Color) + + fun updateCursorThickness(thickness: Dp) } \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt index bfe2b86bba5eb..a55482941973b 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt @@ -45,10 +45,13 @@ import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.uikit.density import androidx.compose.ui.uikit.utils.CMPEditMenuCustomAction import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.asCGRect import androidx.compose.ui.unit.asDpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max import androidx.compose.ui.unit.toDpRect import androidx.compose.ui.unit.toOffset import androidx.compose.ui.unit.toSize @@ -106,6 +109,7 @@ internal class UIKitTextInputService( } private var currentFocusedRect: Rect? = null + private var cursorThickness = 2.dp /** * Workaround to prevent calling textWillChange, textDidChange, selectionWillChange, and @@ -528,6 +532,11 @@ internal class UIKitTextInputService( setupTintColor() } + override fun updateCursorThickness(thickness: Dp) { + // Cursor frame must be at least 1 dp width to make it interactive + cursorThickness = max(thickness, 1.dp) + } + // The Menu appearance is controlled by UIKit. // Return `Hidden` to make Compose always provide a new set of actions when selection changes. override val status: TextToolbarStatus get() = TextToolbarStatus.Hidden @@ -874,7 +883,11 @@ internal class UIKitTextInputService( return null } val rect = currentTextLayoutResult.getCursorRect(position) - return rect.toDpRect(view.density) + return rect.toDpRect(view.density).let { + val hafWidth = cursorThickness / 2 + val center = (it.left + it.right) / 2 + it.copy(left = center - hafWidth, right = center + hafWidth) + } } override fun selectionDpRectsForRange(range: TextRange): List { From 78ec253a66cd9e0fe514a2b2692437fad166bde5 Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin Date: Mon, 8 Dec 2025 01:54:55 +0400 Subject: [PATCH 14/14] Fixed crash with text autocorrection --- .../compose/ui/platform/UIKitTextInputService.uikit.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt index a55482941973b..47fc3624371b0 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/UIKitTextInputService.uikit.kt @@ -1050,8 +1050,11 @@ internal class UIKitTextInputService( TODO("Not yet implemented") } - private fun isIncorrect(range: TextRange): Boolean = - range.start < 0 || range.end > endOfDocument() || range.start > range.end + private fun isIncorrect(range: TextRange): Boolean { + // There might be a desynchronization between a Compose text processing and UIKit's calls of UITextInput methods + val layoutTextEnd = textLayoutResult?.multiParagraph?.intrinsics?.annotatedString?.length ?: 0 + return range.start < 0 || range.end > endOfDocument() || range.end > layoutTextEnd || range.start > range.end + } } }