1 /*
<lambda>null2 * Copyright 2020 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 @file:Suppress("DEPRECATION")
18
19 package androidx.compose.foundation.text
20
21 import androidx.compose.foundation.text.selection.visibleBounds
22 import androidx.compose.ui.geometry.Offset
23 import androidx.compose.ui.geometry.Rect
24 import androidx.compose.ui.geometry.Size
25 import androidx.compose.ui.graphics.Canvas
26 import androidx.compose.ui.graphics.Color
27 import androidx.compose.ui.graphics.Paint
28 import androidx.compose.ui.graphics.isUnspecified
29 import androidx.compose.ui.layout.LayoutCoordinates
30 import androidx.compose.ui.layout.findRootCoordinates
31 import androidx.compose.ui.text.AnnotatedString
32 import androidx.compose.ui.text.Paragraph
33 import androidx.compose.ui.text.SpanStyle
34 import androidx.compose.ui.text.TextLayoutResult
35 import androidx.compose.ui.text.TextPainter
36 import androidx.compose.ui.text.TextRange
37 import androidx.compose.ui.text.TextStyle
38 import androidx.compose.ui.text.font.FontFamily
39 import androidx.compose.ui.text.input.EditCommand
40 import androidx.compose.ui.text.input.EditProcessor
41 import androidx.compose.ui.text.input.ImeAction
42 import androidx.compose.ui.text.input.ImeOptions
43 import androidx.compose.ui.text.input.OffsetMapping
44 import androidx.compose.ui.text.input.TextFieldValue
45 import androidx.compose.ui.text.input.TextInputService
46 import androidx.compose.ui.text.input.TextInputSession
47 import androidx.compose.ui.text.input.TransformedText
48 import androidx.compose.ui.text.style.TextDecoration
49 import androidx.compose.ui.text.style.TextOverflow
50 import androidx.compose.ui.unit.Constraints
51 import androidx.compose.ui.unit.Density
52 import androidx.compose.ui.unit.IntSize
53 import androidx.compose.ui.unit.LayoutDirection
54 import kotlin.jvm.JvmStatic
55 import kotlin.math.max
56 import kotlin.math.min
57
58 // visible for testing
59 internal const val DefaultWidthCharCount = 10 // min width for TextField is 10 chars long
60 internal val EmptyTextReplacement = "H".repeat(DefaultWidthCharCount) // just a reference character.
61
62 /**
63 * Computed the default width and height for TextField.
64 *
65 * The bounding box or x-advance of the empty text is empty, i.e. 0x0 box or 0px advance. However
66 * this is not useful for TextField since text field want to reserve some amount of height for
67 * accepting touch for starting text input. In Android, uses FontMetrics of the first font in the
68 * fallback chain to compute this height, this is because custom font may have different
69 * ascender/descender from the default font in Android.
70 *
71 * Until we have font metrics APIs, use the height of reference text as a workaround.
72 */
73 internal fun computeSizeForDefaultText(
74 style: TextStyle,
75 density: Density,
76 fontFamilyResolver: FontFamily.Resolver,
77 text: String = EmptyTextReplacement,
78 maxLines: Int = 1
79 ): IntSize {
80 val paragraph =
81 Paragraph(
82 text = text,
83 style = style,
84 spanStyles = listOf(),
85 maxLines = maxLines,
86 overflow = TextOverflow.Clip,
87 density = density,
88 fontFamilyResolver = fontFamilyResolver,
89 constraints = Constraints()
90 )
91 return IntSize(paragraph.minIntrinsicWidth.ceilToIntPx(), paragraph.height.ceilToIntPx())
92 }
93
94 internal class TextFieldDelegate {
95 companion object {
96 /**
97 * Process text layout with given constraint.
98 *
99 * @param textDelegate The text painter
100 * @param constraints The layout constraints
101 * @return the bounding box size(width and height) of the layout result
102 */
103 @JvmStatic
layoutnull104 internal fun layout(
105 textDelegate: TextDelegate,
106 constraints: Constraints,
107 layoutDirection: LayoutDirection,
108 prevResultText: TextLayoutResult? = null
109 ): Triple<Int, Int, TextLayoutResult> {
110 val layoutResult = textDelegate.layout(constraints, layoutDirection, prevResultText)
111 return Triple(layoutResult.size.width, layoutResult.size.height, layoutResult)
112 }
113
114 /**
115 * Draw the text content to the canvas
116 *
117 * @param canvas The target canvas.
118 * @param value The editor state
119 * @param selectionPreviewHighlightRange Range to be highlighted to preview a handwriting
120 * selection gesture
121 * @param deletionPreviewHighlightRange Range to be highlighted to preview a handwriting
122 * deletion gesture
123 * @param offsetMapping The offset map
124 * @param textLayoutResult The text layout result
125 * @param highlightPaint Paint used to draw highlight backgrounds
126 * @param selectionBackgroundColor The selection highlight background color
127 */
128 @JvmStatic
drawnull129 internal fun draw(
130 canvas: Canvas,
131 value: TextFieldValue,
132 selectionPreviewHighlightRange: TextRange,
133 deletionPreviewHighlightRange: TextRange,
134 offsetMapping: OffsetMapping,
135 textLayoutResult: TextLayoutResult,
136 highlightPaint: Paint,
137 selectionBackgroundColor: Color
138 ) {
139 if (!selectionPreviewHighlightRange.collapsed) {
140 highlightPaint.color = selectionBackgroundColor
141 drawHighlight(
142 canvas,
143 selectionPreviewHighlightRange,
144 offsetMapping,
145 textLayoutResult,
146 highlightPaint
147 )
148 } else if (!deletionPreviewHighlightRange.collapsed) {
149 val textColor =
150 textLayoutResult.layoutInput.style.color.takeUnless { it.isUnspecified }
151 ?: Color.Black
152 highlightPaint.color = textColor.copy(alpha = textColor.alpha * 0.2f)
153 drawHighlight(
154 canvas,
155 deletionPreviewHighlightRange,
156 offsetMapping,
157 textLayoutResult,
158 highlightPaint
159 )
160 } else if (!value.selection.collapsed) {
161 highlightPaint.color = selectionBackgroundColor
162 drawHighlight(
163 canvas,
164 value.selection,
165 offsetMapping,
166 textLayoutResult,
167 highlightPaint
168 )
169 }
170 TextPainter.paint(canvas, textLayoutResult)
171 }
172
drawHighlightnull173 private fun drawHighlight(
174 canvas: Canvas,
175 range: TextRange,
176 offsetMapping: OffsetMapping,
177 textLayoutResult: TextLayoutResult,
178 paint: Paint
179 ) {
180 val start = offsetMapping.originalToTransformed(range.min)
181 val end = offsetMapping.originalToTransformed(range.max)
182 if (start != end) {
183 val selectionPath = textLayoutResult.getPathForRange(start, end)
184 canvas.drawPath(selectionPath, paint)
185 }
186 }
187
188 /**
189 * Notify system that focused input area.
190 *
191 * @param value The editor model
192 * @param textDelegate The text delegate
193 * @param layoutCoordinates The layout coordinates
194 * @param textInputSession The current input session.
195 * @param hasFocus True if focus is gained.
196 * @param offsetMapping The mapper from/to editing buffer to/from visible text.
197 */
198 // TODO(b/262648050) Try to find a better API.
199 @JvmStatic
notifyFocusedRectnull200 internal fun notifyFocusedRect(
201 value: TextFieldValue,
202 textDelegate: TextDelegate,
203 textLayoutResult: TextLayoutResult,
204 layoutCoordinates: LayoutCoordinates,
205 textInputSession: TextInputSession,
206 hasFocus: Boolean,
207 offsetMapping: OffsetMapping
208 ) {
209 if (!hasFocus) {
210 return
211 }
212
213 textInputSession.notifyFocusedRect(
214 focusedRectInRoot(
215 layoutResult = textLayoutResult,
216 layoutCoordinates = layoutCoordinates,
217 focusOffset = offsetMapping.originalToTransformed(value.selection.max),
218 sizeForDefaultText = {
219 computeSizeForDefaultText(
220 textDelegate.style,
221 textDelegate.density,
222 textDelegate.fontFamilyResolver
223 )
224 }
225 )
226 )
227 }
228
229 /**
230 * Notify the input service of layout and position changes.
231 *
232 * @param textInputSession the current input session
233 * @param textFieldValue the editor state
234 * @param offsetMapping the offset mapping for the visual transformation
235 * @param textLayoutResult the layout result
236 */
237 @JvmStatic
updateTextLayoutResultnull238 internal fun updateTextLayoutResult(
239 textInputSession: TextInputSession,
240 textFieldValue: TextFieldValue,
241 offsetMapping: OffsetMapping,
242 textLayoutResult: TextLayoutResultProxy
243 ) {
244 textLayoutResult.innerTextFieldCoordinates?.let { innerTextFieldCoordinates ->
245 if (!innerTextFieldCoordinates.isAttached) return
246 textLayoutResult.decorationBoxCoordinates?.let { decorationBoxCoordinates ->
247 textInputSession.updateTextLayoutResult(
248 textFieldValue,
249 offsetMapping,
250 textLayoutResult.value,
251 { matrix ->
252 if (innerTextFieldCoordinates.isAttached) {
253 innerTextFieldCoordinates
254 .findRootCoordinates()
255 .transformFrom(innerTextFieldCoordinates, matrix)
256 }
257 },
258 innerTextFieldCoordinates.visibleBounds(),
259 innerTextFieldCoordinates.localBoundingBoxOf(
260 decorationBoxCoordinates,
261 clipBounds = false
262 )
263 )
264 }
265 }
266 }
267
268 /**
269 * Called when edit operations are passed from TextInputService
270 *
271 * @param ops A list of edit operations.
272 * @param editProcessor The edit processor
273 * @param onValueChange The callback called when the new editor state arrives.
274 */
275 @JvmStatic
onEditCommandnull276 internal fun onEditCommand(
277 ops: List<EditCommand>,
278 editProcessor: EditProcessor,
279 onValueChange: (TextFieldValue) -> Unit,
280 session: TextInputSession?
281 ) {
282 val newValue = editProcessor.apply(ops)
283
284 // Android: Some IME calls getTextBeforeCursor API just after the setComposingText. The
285 // getTextBeforeCursor may return the text without a text set by setComposingText
286 // because the text field state in the application code is updated on the next time
287 // composition. On the other hand, some IME gets confused and cancel the composition
288 // because the text set by setComposingText is not available.
289 // To avoid this problem, update the state in the TextInputService to the latest
290 // plausible state. When the real state comes, the TextInputService will compare and
291 // update the state if it is modified by developers.
292 session?.updateState(null, newValue)
293 onValueChange(newValue)
294 }
295
296 /**
297 * Sets the cursor position. Should be called when TextField has focus.
298 *
299 * @param position The event position in composable coordinate.
300 * @param textLayoutResult The text layout result proxy
301 * @param editProcessor The edit processor
302 * @param offsetMapping The offset map
303 * @param onValueChange The callback called when the new editor state arrives.
304 */
305 @JvmStatic
setCursorOffsetnull306 internal fun setCursorOffset(
307 position: Offset,
308 textLayoutResult: TextLayoutResultProxy,
309 editProcessor: EditProcessor,
310 offsetMapping: OffsetMapping,
311 onValueChange: (TextFieldValue) -> Unit
312 ) {
313 val offset =
314 offsetMapping.transformedToOriginal(textLayoutResult.getOffsetForPosition(position))
315 onValueChange(editProcessor.toTextFieldValue().copy(selection = TextRange(offset)))
316 }
317
318 /**
319 * Starts a new input connection.
320 *
321 * @param textInputService The text input service
322 * @param value The editor state
323 * @param editProcessor The edit processor
324 * @param onValueChange The callback called when the new editor state arrives.
325 * @param onImeActionPerformed The callback called when the editor action arrives.
326 * @param imeOptions Keyboard configuration such as single line, auto correct etc.
327 */
328 @JvmStatic
restartInputnull329 internal fun restartInput(
330 textInputService: TextInputService,
331 value: TextFieldValue,
332 editProcessor: EditProcessor,
333 imeOptions: ImeOptions,
334 onValueChange: (TextFieldValue) -> Unit,
335 onImeActionPerformed: (ImeAction) -> Unit
336 ): TextInputSession {
337 var session: TextInputSession? = null
338 session =
339 textInputService.startInput(
340 value = value,
341 imeOptions = imeOptions,
342 onEditCommand = { onEditCommand(it, editProcessor, onValueChange, session) },
343 onImeActionPerformed = onImeActionPerformed
344 )
345 return session
346 }
347
348 /**
349 * Called when the composable gained input focus
350 *
351 * @param textInputService The text input service
352 * @param value The editor state
353 * @param editProcessor The edit processor
354 * @param onValueChange The callback called when the new editor state arrives.
355 * @param onImeActionPerformed The callback called when the editor action arrives.
356 * @param imeOptions Keyboard configuration such as single line, auto correct etc.
357 */
358 @JvmStatic
onFocusnull359 internal fun onFocus(
360 textInputService: TextInputService,
361 value: TextFieldValue,
362 editProcessor: EditProcessor,
363 imeOptions: ImeOptions,
364 onValueChange: (TextFieldValue) -> Unit,
365 onImeActionPerformed: (ImeAction) -> Unit
366 ): TextInputSession {
367 // The keyboard will automatically be shown when the new IME connection is started.
368 return restartInput(
369 textInputService = textInputService,
370 value = value,
371 editProcessor = editProcessor,
372 imeOptions = imeOptions,
373 onValueChange = onValueChange,
374 onImeActionPerformed = onImeActionPerformed
375 )
376 }
377
378 /**
379 * Called when the composable loses input focus
380 *
381 * @param textInputSession The current input session.
382 * @param editProcessor The edit processor
383 * @param onValueChange The callback called when the new editor state arrives.
384 */
385 @JvmStatic
onBlurnull386 internal fun onBlur(
387 textInputSession: TextInputSession,
388 editProcessor: EditProcessor,
389 onValueChange: (TextFieldValue) -> Unit
390 ) {
391 onValueChange(editProcessor.toTextFieldValue().copy(composition = null))
392 // Don't hide the keyboard when losing focus. If the target system needs that behavior,
393 // it can be implemented in the PlatformTextInputService.
394 textInputSession.dispose()
395 }
396
397 /**
398 * Apply the composition text decoration (undeline) to the transformed text.
399 *
400 * @param compositionRange An input state
401 * @param transformed A transformed text
402 * @return The transformed text with composition decoration.
403 */
applyCompositionDecorationnull404 fun applyCompositionDecoration(
405 compositionRange: TextRange,
406 transformed: TransformedText
407 ): TransformedText {
408 val startPositionTransformed =
409 transformed.offsetMapping.originalToTransformed(compositionRange.start)
410 val endPositionTransformed =
411 transformed.offsetMapping.originalToTransformed(compositionRange.end)
412
413 // coerce into a valid range with start <= end
414 val start = min(startPositionTransformed, endPositionTransformed)
415 val coercedEnd = max(startPositionTransformed, endPositionTransformed)
416 return TransformedText(
417 AnnotatedString.Builder(transformed.text)
418 .apply {
419 addStyle(
420 SpanStyle(textDecoration = TextDecoration.Underline),
421 start,
422 coercedEnd
423 )
424 }
425 .toAnnotatedString(),
426 transformed.offsetMapping
427 )
428 }
429 }
430 }
431
432 /** Computes the bounds of the area where text editing is in progress, relative to the root. */
focusedRectInRootnull433 internal fun focusedRectInRoot(
434 layoutResult: TextLayoutResult,
435 layoutCoordinates: LayoutCoordinates,
436 focusOffset: Int,
437 sizeForDefaultText: () -> IntSize
438 ): Rect {
439 val bbox =
440 when {
441 focusOffset < layoutResult.layoutInput.text.length -> {
442 layoutResult.getBoundingBox(focusOffset)
443 }
444 focusOffset != 0 -> {
445 layoutResult.getBoundingBox(focusOffset - 1)
446 }
447 else -> { // empty text.
448 val size = sizeForDefaultText()
449 Rect(0f, 0f, 1.0f, size.height.toFloat())
450 }
451 }
452 val globalLT = layoutCoordinates.localToRoot(Offset(bbox.left, bbox.top))
453 return Rect(Offset(globalLT.x, globalLT.y), Size(bbox.width, bbox.height))
454 }
455