Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 1 | /* |
| 2 | * Copyright 2023 The Android Open Source Project |
| 3 | * |
| 4 | * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | * you may not use this file except in compliance with the License. |
| 6 | * You may obtain a copy of the License at |
| 7 | * |
| 8 | * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | * |
| 10 | * Unless required by applicable law or agreed to in writing, software |
| 11 | * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | * See the License for the specific language governing permissions and |
| 14 | * limitations under the License. |
| 15 | */ |
| 16 | |
| 17 | package androidx.compose.foundation.text2.input.internal |
| 18 | |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 19 | import androidx.compose.foundation.ExperimentalFoundationApi |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 20 | import androidx.compose.runtime.getValue |
| 21 | import androidx.compose.runtime.mutableStateOf |
Halil Ozercan | a0c31cf | 2023-06-05 15:08:02 +0100 | [diff] [blame] | 22 | import androidx.compose.runtime.neverEqualPolicy |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 23 | import androidx.compose.runtime.setValue |
Halil Ozercan | 589b26b | 2023-06-07 14:04:22 +0100 | [diff] [blame] | 24 | import androidx.compose.ui.geometry.Offset |
| 25 | import androidx.compose.ui.geometry.Rect |
Halil Ozercan | a0c31cf | 2023-06-05 15:08:02 +0100 | [diff] [blame] | 26 | import androidx.compose.ui.layout.LayoutCoordinates |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 27 | import androidx.compose.ui.text.TextLayoutResult |
| 28 | import androidx.compose.ui.text.TextStyle |
| 29 | import androidx.compose.ui.text.font.FontFamily |
| 30 | import androidx.compose.ui.unit.Constraints |
| 31 | import androidx.compose.ui.unit.Density |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 32 | import androidx.compose.ui.unit.LayoutDirection |
Halil Ozercan | 3b36c7e | 2023-06-07 14:04:22 +0100 | [diff] [blame] | 33 | import androidx.compose.ui.unit.dp |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 34 | |
Halil Ozercan | a0c31cf | 2023-06-05 15:08:02 +0100 | [diff] [blame] | 35 | /** |
| 36 | * Manages text layout for TextField including layout coordinates of decoration box and inner text |
| 37 | * field. |
| 38 | */ |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 39 | @OptIn(ExperimentalFoundationApi::class) |
Halil Ozercan | a0c31cf | 2023-06-05 15:08:02 +0100 | [diff] [blame] | 40 | internal class TextLayoutState { |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 41 | private var layoutCache = TextFieldLayoutStateCache() |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 42 | |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 43 | var onTextLayout: (Density.(() -> TextLayoutResult?) -> Unit)? = null |
Zach Klippenstein | cb364a5 | 2023-08-03 13:07:06 -0700 | [diff] [blame] | 44 | |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 45 | val layoutResult: TextLayoutResult? by layoutCache |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 46 | |
Halil Ozercan | fddf9d5 | 2023-10-06 13:39:54 +0100 | [diff] [blame] | 47 | /** |
| 48 | * Measured layout coordinates of the decoration box, core text field, and text layout node. |
| 49 | * |
| 50 | * DecoratorNode |
| 51 | * ------------------- |
| 52 | * | CoreNode |--> Outer Decoration Box with padding |
| 53 | * | ------------- | |
| 54 | * | | | | |
| 55 | * | | |--|--> Visible inner text field |
| 56 | * | ------------- | (Below the dashed line is not visible) |
| 57 | * | | | | |
| 58 | * | | | | |
| 59 | * ------------------- |
| 60 | * | | |
| 61 | * | |---> Scrollable part (TextLayoutNode) |
| 62 | * ------------- |
| 63 | * |
| 64 | * These coordinates are used to calculate the relative positioning between multiple layers |
| 65 | * of a BasicTextField. For example, touches are processed by the decoration box but these |
| 66 | * should be converted to text layout positions to find out which character is pressed. |
Halil Ozercan | a0c31cf | 2023-06-05 15:08:02 +0100 | [diff] [blame] | 67 | * |
| 68 | * [LayoutCoordinates] object returned from onGloballyPositioned callback is usually the same |
| 69 | * instance unless a node is detached and re-attached to the tree. To react to layout and |
| 70 | * positional changes even though the object never changes, we employ a neverEqualPolicy. |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 71 | */ |
Halil Ozercan | fddf9d5 | 2023-10-06 13:39:54 +0100 | [diff] [blame] | 72 | var textLayoutNodeCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy()) |
| 73 | var coreNodeCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy()) |
| 74 | var decoratorNodeCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy()) |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 75 | |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 76 | /** |
Halil Ozercan | 3b36c7e | 2023-06-07 14:04:22 +0100 | [diff] [blame] | 77 | * Set to a non-zero value for single line TextFields in order to prevent text cuts. |
| 78 | */ |
| 79 | var minHeightForSingleLineField by mutableStateOf(0.dp) |
| 80 | |
| 81 | /** |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 82 | * Updates the [TextFieldLayoutStateCache] with inputs that don't come from the measure phase. |
| 83 | * This method will initialize the cache the first time it's called. |
| 84 | * If the new inputs require re-calculating text layout, any readers of [layoutResult] called |
| 85 | * from a snapshot observer will be invalidated. |
| 86 | * |
| 87 | * @see layoutWithNewMeasureInputs |
| 88 | */ |
| 89 | fun updateNonMeasureInputs( |
Zach Klippenstein | 2adbc54 | 2023-08-13 22:21:23 -0700 | [diff] [blame] | 90 | textFieldState: TransformedTextFieldState, |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 91 | textStyle: TextStyle, |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 92 | singleLine: Boolean, |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 93 | softWrap: Boolean, |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 94 | ) { |
| 95 | layoutCache.updateNonMeasureInputs( |
| 96 | textFieldState = textFieldState, |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 97 | textStyle = textStyle, |
| 98 | singleLine = singleLine, |
| 99 | softWrap = softWrap, |
| 100 | ) |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Updates the [TextFieldLayoutStateCache] with inputs that come from the measure phase and returns the |
| 105 | * latest [TextLayoutResult]. If the measure inputs haven't changed significantly since the |
| 106 | * last call, this will be the cached result. If the new inputs require re-calculating text |
| 107 | * layout, any readers of [layoutResult] called from a snapshot observer will be invalidated. |
| 108 | * |
| 109 | * [updateNonMeasureInputs] must be called before this method to initialize the cache. |
| 110 | */ |
| 111 | fun layoutWithNewMeasureInputs( |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 112 | density: Density, |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 113 | layoutDirection: LayoutDirection, |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 114 | fontFamilyResolver: FontFamily.Resolver, |
| 115 | constraints: Constraints, |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 116 | ): TextLayoutResult { |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 117 | val layoutResult = layoutCache.layoutWithNewMeasureInputs( |
| 118 | density = density, |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 119 | layoutDirection = layoutDirection, |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 120 | fontFamilyResolver = fontFamilyResolver, |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 121 | constraints = constraints, |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 122 | ) |
| 123 | |
| 124 | onTextLayout?.let { onTextLayout -> |
| 125 | val textLayoutProvider = { layoutCache.value } |
| 126 | onTextLayout(density, textLayoutProvider) |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 127 | } |
Zach Klippenstein | 97b1bc1 | 2023-07-28 13:47:26 -0700 | [diff] [blame] | 128 | |
| 129 | return layoutResult |
Zach Klippenstein | d7bcf71 | 2023-04-27 16:03:39 +0000 | [diff] [blame] | 130 | } |
Halil Ozercan | 589b26b | 2023-06-07 14:04:22 +0100 | [diff] [blame] | 131 | |
| 132 | /** |
| 133 | * Translates the position of the touch on the screen to the position in text. Because touch |
| 134 | * is relative to the decoration box, we need to translate it to the inner text field's |
| 135 | * coordinates first before calculating position of the symbol in text. |
| 136 | * |
| 137 | * @param position original position of the gesture relative to the decoration box |
| 138 | * @param coerceInVisibleBounds if true and original [position] is outside visible bounds |
| 139 | * of the inner text field, the [position] will be shifted to the closest edge of the inner |
| 140 | * text field's visible bounds. This is useful when you have a decoration box |
| 141 | * bigger than the inner text field, so when user touches to the decoration box area, the cursor |
| 142 | * goes to the beginning or the end of the visible inner text field; otherwise if we put the |
| 143 | * cursor under the touch in the invisible part of the inner text field, it would scroll to |
| 144 | * make the cursor visible. This behavior is not needed, and therefore |
| 145 | * [coerceInVisibleBounds] should be set to false, when the user drags outside visible bounds |
| 146 | * to make a selection. |
| 147 | * @return The offset that corresponds to the [position]. Returns -1 if text layout has not |
| 148 | * been measured yet. |
| 149 | */ |
| 150 | fun getOffsetForPosition(position: Offset, coerceInVisibleBounds: Boolean = true): Int { |
| 151 | val layoutResult = layoutResult ?: return -1 |
Halil Ozercan | fddf9d5 | 2023-10-06 13:39:54 +0100 | [diff] [blame] | 152 | val coercedPosition = if (coerceInVisibleBounds) { |
| 153 | position.coercedInVisibleBoundsOfInputText() |
| 154 | } else { |
| 155 | position |
| 156 | } |
| 157 | val relativePosition = fromDecorationToTextLayout(coercedPosition) |
Halil Ozercan | 589b26b | 2023-06-07 14:04:22 +0100 | [diff] [blame] | 158 | return layoutResult.getOffsetForPosition(relativePosition) |
| 159 | } |
| 160 | |
| 161 | /** |
Halil Ozercan | 094ff70 | 2023-06-20 16:23:11 +0100 | [diff] [blame] | 162 | * Returns true if the screen coordinates position (x,y) corresponds to a character displayed |
| 163 | * in the view. Returns false when the position is in the empty space of left/right of text. |
| 164 | * This function may return true even when [offset] is below or above the text layout. |
| 165 | */ |
| 166 | fun isPositionOnText(offset: Offset): Boolean { |
| 167 | val layoutResult = layoutResult ?: return false |
Halil Ozercan | fddf9d5 | 2023-10-06 13:39:54 +0100 | [diff] [blame] | 168 | val relativeOffset = fromDecorationToTextLayout(offset.coercedInVisibleBoundsOfInputText()) |
Halil Ozercan | 094ff70 | 2023-06-20 16:23:11 +0100 | [diff] [blame] | 169 | val line = layoutResult.getLineForVerticalPosition(relativeOffset.y) |
| 170 | return relativeOffset.x >= layoutResult.getLineLeft(line) && |
| 171 | relativeOffset.x <= layoutResult.getLineRight(line) |
| 172 | } |
| 173 | |
| 174 | /** |
Halil Ozercan | 589b26b | 2023-06-07 14:04:22 +0100 | [diff] [blame] | 175 | * If click on the decoration box happens outside visible inner text field, coerce the click |
| 176 | * position to the visible edges of the inner text field. |
| 177 | */ |
| 178 | private fun Offset.coercedInVisibleBoundsOfInputText(): Offset { |
| 179 | // If offset is outside visible bounds of the inner text field, use visible bounds edges |
Halil Ozercan | fddf9d5 | 2023-10-06 13:39:54 +0100 | [diff] [blame] | 180 | val visibleTextLayoutNodeRect = |
| 181 | textLayoutNodeCoordinates?.let { textLayoutNodeCoordinates -> |
| 182 | if (textLayoutNodeCoordinates.isAttached) { |
| 183 | decoratorNodeCoordinates?.localBoundingBoxOf(textLayoutNodeCoordinates) |
Halil Ozercan | 589b26b | 2023-06-07 14:04:22 +0100 | [diff] [blame] | 184 | } else { |
| 185 | Rect.Zero |
| 186 | } |
| 187 | } ?: Rect.Zero |
Halil Ozercan | fddf9d5 | 2023-10-06 13:39:54 +0100 | [diff] [blame] | 188 | return this.coerceIn(visibleTextLayoutNodeRect) |
Halil Ozercan | 589b26b | 2023-06-07 14:04:22 +0100 | [diff] [blame] | 189 | } |
Halil Ozercan | a0c31cf | 2023-06-05 15:08:02 +0100 | [diff] [blame] | 190 | } |
Halil Ozercan | 589b26b | 2023-06-07 14:04:22 +0100 | [diff] [blame] | 191 | |
Halil Ozercan | 271ff79 | 2023-07-10 15:31:55 +0100 | [diff] [blame] | 192 | internal fun Offset.coerceIn(rect: Rect): Offset { |
Halil Ozercan | 589b26b | 2023-06-07 14:04:22 +0100 | [diff] [blame] | 193 | val xOffset = when { |
| 194 | x < rect.left -> rect.left |
| 195 | x > rect.right -> rect.right |
| 196 | else -> x |
| 197 | } |
| 198 | val yOffset = when { |
| 199 | y < rect.top -> rect.top |
| 200 | y > rect.bottom -> rect.bottom |
| 201 | else -> y |
| 202 | } |
| 203 | return Offset(xOffset, yOffset) |
Aurimas Liutikas | 4d53400 | 2023-07-06 15:51:33 -0700 | [diff] [blame] | 204 | } |
Halil Ozercan | 271ff79 | 2023-07-10 15:31:55 +0100 | [diff] [blame] | 205 | |
| 206 | /** |
Halil Ozercan | fddf9d5 | 2023-10-06 13:39:54 +0100 | [diff] [blame] | 207 | * Translates a position from text layout node coordinates to core node coordinates. |
Halil Ozercan | 271ff79 | 2023-07-10 15:31:55 +0100 | [diff] [blame] | 208 | */ |
Halil Ozercan | fddf9d5 | 2023-10-06 13:39:54 +0100 | [diff] [blame] | 209 | internal fun TextLayoutState.fromTextLayoutToCore(offset: Offset): Offset { |
| 210 | return textLayoutNodeCoordinates?.takeIf { it.isAttached }?.let { textLayoutNodeCoordinates -> |
| 211 | coreNodeCoordinates?.takeIf { it.isAttached }?.let { coreNodeCoordinates -> |
| 212 | coreNodeCoordinates.localPositionOf(textLayoutNodeCoordinates, offset) |
| 213 | } |
| 214 | } ?: offset |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Translates the click happened on the decorator node to the position in the text layout node |
| 219 | * coordinates. This relative position is then used to determine symbol position in text using |
| 220 | * TextLayoutResult object. |
| 221 | */ |
| 222 | internal fun TextLayoutState.fromDecorationToTextLayout(offset: Offset): Offset { |
| 223 | return textLayoutNodeCoordinates?.let { textLayoutNodeCoordinates -> |
| 224 | decoratorNodeCoordinates?.let { decoratorNodeCoordinates -> |
| 225 | if (textLayoutNodeCoordinates.isAttached && decoratorNodeCoordinates.isAttached) { |
| 226 | textLayoutNodeCoordinates.localPositionOf(decoratorNodeCoordinates, offset) |
| 227 | } else { |
| 228 | offset |
| 229 | } |
Halil Ozercan | 271ff79 | 2023-07-10 15:31:55 +0100 | [diff] [blame] | 230 | } |
| 231 | } ?: offset |
| 232 | } |