blob: 29df3cd619bd40e49721f1c45b309edaee117881 [file] [log] [blame]
Zach Klippensteind7bcf712023-04-27 16:03:39 +00001/*
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
17package androidx.compose.foundation.text2.input.internal
18
Zach Klippenstein97b1bc12023-07-28 13:47:26 -070019import androidx.compose.foundation.ExperimentalFoundationApi
Zach Klippensteind7bcf712023-04-27 16:03:39 +000020import androidx.compose.runtime.getValue
21import androidx.compose.runtime.mutableStateOf
Halil Ozercana0c31cf2023-06-05 15:08:02 +010022import androidx.compose.runtime.neverEqualPolicy
Zach Klippensteind7bcf712023-04-27 16:03:39 +000023import androidx.compose.runtime.setValue
Halil Ozercan589b26b2023-06-07 14:04:22 +010024import androidx.compose.ui.geometry.Offset
25import androidx.compose.ui.geometry.Rect
Halil Ozercana0c31cf2023-06-05 15:08:02 +010026import androidx.compose.ui.layout.LayoutCoordinates
Zach Klippensteind7bcf712023-04-27 16:03:39 +000027import androidx.compose.ui.text.TextLayoutResult
28import androidx.compose.ui.text.TextStyle
29import androidx.compose.ui.text.font.FontFamily
30import androidx.compose.ui.unit.Constraints
31import androidx.compose.ui.unit.Density
Zach Klippenstein97b1bc12023-07-28 13:47:26 -070032import androidx.compose.ui.unit.LayoutDirection
Halil Ozercan3b36c7e2023-06-07 14:04:22 +010033import androidx.compose.ui.unit.dp
Zach Klippensteind7bcf712023-04-27 16:03:39 +000034
Halil Ozercana0c31cf2023-06-05 15:08:02 +010035/**
36 * Manages text layout for TextField including layout coordinates of decoration box and inner text
37 * field.
38 */
Zach Klippenstein97b1bc12023-07-28 13:47:26 -070039@OptIn(ExperimentalFoundationApi::class)
Halil Ozercana0c31cf2023-06-05 15:08:02 +010040internal class TextLayoutState {
Zach Klippenstein97b1bc12023-07-28 13:47:26 -070041 private var layoutCache = TextFieldLayoutStateCache()
Zach Klippensteind7bcf712023-04-27 16:03:39 +000042
Zach Klippenstein97b1bc12023-07-28 13:47:26 -070043 var onTextLayout: (Density.(() -> TextLayoutResult?) -> Unit)? = null
Zach Klippensteincb364a52023-08-03 13:07:06 -070044
Zach Klippenstein97b1bc12023-07-28 13:47:26 -070045 val layoutResult: TextLayoutResult? by layoutCache
Zach Klippensteind7bcf712023-04-27 16:03:39 +000046
Halil Ozercanfddf9d52023-10-06 13:39:54 +010047 /**
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 Ozercana0c31cf2023-06-05 15:08:02 +010067 *
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 Klippensteind7bcf712023-04-27 16:03:39 +000071 */
Halil Ozercanfddf9d52023-10-06 13:39:54 +010072 var textLayoutNodeCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
73 var coreNodeCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
74 var decoratorNodeCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
Zach Klippensteind7bcf712023-04-27 16:03:39 +000075
Zach Klippenstein97b1bc12023-07-28 13:47:26 -070076 /**
Halil Ozercan3b36c7e2023-06-07 14:04:22 +010077 * 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 Klippenstein97b1bc12023-07-28 13:47:26 -070082 * 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 Klippenstein2adbc542023-08-13 22:21:23 -070090 textFieldState: TransformedTextFieldState,
Zach Klippensteind7bcf712023-04-27 16:03:39 +000091 textStyle: TextStyle,
Zach Klippenstein97b1bc12023-07-28 13:47:26 -070092 singleLine: Boolean,
Zach Klippensteind7bcf712023-04-27 16:03:39 +000093 softWrap: Boolean,
Zach Klippenstein97b1bc12023-07-28 13:47:26 -070094 ) {
95 layoutCache.updateNonMeasureInputs(
96 textFieldState = textFieldState,
Zach Klippenstein97b1bc12023-07-28 13:47:26 -070097 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 Klippensteind7bcf712023-04-27 16:03:39 +0000112 density: Density,
Zach Klippenstein97b1bc12023-07-28 13:47:26 -0700113 layoutDirection: LayoutDirection,
Zach Klippensteind7bcf712023-04-27 16:03:39 +0000114 fontFamilyResolver: FontFamily.Resolver,
115 constraints: Constraints,
Zach Klippensteind7bcf712023-04-27 16:03:39 +0000116 ): TextLayoutResult {
Zach Klippenstein97b1bc12023-07-28 13:47:26 -0700117 val layoutResult = layoutCache.layoutWithNewMeasureInputs(
118 density = density,
Zach Klippensteind7bcf712023-04-27 16:03:39 +0000119 layoutDirection = layoutDirection,
Zach Klippenstein97b1bc12023-07-28 13:47:26 -0700120 fontFamilyResolver = fontFamilyResolver,
Zach Klippensteind7bcf712023-04-27 16:03:39 +0000121 constraints = constraints,
Zach Klippenstein97b1bc12023-07-28 13:47:26 -0700122 )
123
124 onTextLayout?.let { onTextLayout ->
125 val textLayoutProvider = { layoutCache.value }
126 onTextLayout(density, textLayoutProvider)
Zach Klippensteind7bcf712023-04-27 16:03:39 +0000127 }
Zach Klippenstein97b1bc12023-07-28 13:47:26 -0700128
129 return layoutResult
Zach Klippensteind7bcf712023-04-27 16:03:39 +0000130 }
Halil Ozercan589b26b2023-06-07 14:04:22 +0100131
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 Ozercanfddf9d52023-10-06 13:39:54 +0100152 val coercedPosition = if (coerceInVisibleBounds) {
153 position.coercedInVisibleBoundsOfInputText()
154 } else {
155 position
156 }
157 val relativePosition = fromDecorationToTextLayout(coercedPosition)
Halil Ozercan589b26b2023-06-07 14:04:22 +0100158 return layoutResult.getOffsetForPosition(relativePosition)
159 }
160
161 /**
Halil Ozercan094ff702023-06-20 16:23:11 +0100162 * 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 Ozercanfddf9d52023-10-06 13:39:54 +0100168 val relativeOffset = fromDecorationToTextLayout(offset.coercedInVisibleBoundsOfInputText())
Halil Ozercan094ff702023-06-20 16:23:11 +0100169 val line = layoutResult.getLineForVerticalPosition(relativeOffset.y)
170 return relativeOffset.x >= layoutResult.getLineLeft(line) &&
171 relativeOffset.x <= layoutResult.getLineRight(line)
172 }
173
174 /**
Halil Ozercan589b26b2023-06-07 14:04:22 +0100175 * 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 Ozercanfddf9d52023-10-06 13:39:54 +0100180 val visibleTextLayoutNodeRect =
181 textLayoutNodeCoordinates?.let { textLayoutNodeCoordinates ->
182 if (textLayoutNodeCoordinates.isAttached) {
183 decoratorNodeCoordinates?.localBoundingBoxOf(textLayoutNodeCoordinates)
Halil Ozercan589b26b2023-06-07 14:04:22 +0100184 } else {
185 Rect.Zero
186 }
187 } ?: Rect.Zero
Halil Ozercanfddf9d52023-10-06 13:39:54 +0100188 return this.coerceIn(visibleTextLayoutNodeRect)
Halil Ozercan589b26b2023-06-07 14:04:22 +0100189 }
Halil Ozercana0c31cf2023-06-05 15:08:02 +0100190}
Halil Ozercan589b26b2023-06-07 14:04:22 +0100191
Halil Ozercan271ff792023-07-10 15:31:55 +0100192internal fun Offset.coerceIn(rect: Rect): Offset {
Halil Ozercan589b26b2023-06-07 14:04:22 +0100193 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 Liutikas4d534002023-07-06 15:51:33 -0700204}
Halil Ozercan271ff792023-07-10 15:31:55 +0100205
206/**
Halil Ozercanfddf9d52023-10-06 13:39:54 +0100207 * Translates a position from text layout node coordinates to core node coordinates.
Halil Ozercan271ff792023-07-10 15:31:55 +0100208 */
Halil Ozercanfddf9d52023-10-06 13:39:54 +0100209internal 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 */
222internal 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 Ozercan271ff792023-07-10 15:31:55 +0100230 }
231 } ?: offset
232}