1 /*
<lambda>null2 * 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.text.modifiers
18
19 import androidx.compose.foundation.text.DefaultMinLines
20 import androidx.compose.foundation.text.TextAutoSize
21 import androidx.compose.ui.Modifier
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.Color
26 import androidx.compose.ui.graphics.ColorProducer
27 import androidx.compose.ui.graphics.Shadow
28 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
29 import androidx.compose.ui.graphics.drawscope.Fill
30 import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
31 import androidx.compose.ui.graphics.isSpecified
32 import androidx.compose.ui.layout.AlignmentLine
33 import androidx.compose.ui.layout.FirstBaseline
34 import androidx.compose.ui.layout.IntrinsicMeasurable
35 import androidx.compose.ui.layout.IntrinsicMeasureScope
36 import androidx.compose.ui.layout.LastBaseline
37 import androidx.compose.ui.layout.Measurable
38 import androidx.compose.ui.layout.MeasureResult
39 import androidx.compose.ui.layout.MeasureScope
40 import androidx.compose.ui.node.DrawModifierNode
41 import androidx.compose.ui.node.LayoutModifierNode
42 import androidx.compose.ui.node.SemanticsModifierNode
43 import androidx.compose.ui.node.invalidateDraw
44 import androidx.compose.ui.node.invalidateLayer
45 import androidx.compose.ui.node.invalidateMeasurement
46 import androidx.compose.ui.node.invalidateSemantics
47 import androidx.compose.ui.semantics.SemanticsPropertyReceiver
48 import androidx.compose.ui.semantics.clearTextSubstitution
49 import androidx.compose.ui.semantics.getTextLayoutResult
50 import androidx.compose.ui.semantics.isShowingTextSubstitution
51 import androidx.compose.ui.semantics.setTextSubstitution
52 import androidx.compose.ui.semantics.showTextSubstitution
53 import androidx.compose.ui.semantics.text
54 import androidx.compose.ui.semantics.textSubstitution
55 import androidx.compose.ui.text.AnnotatedString
56 import androidx.compose.ui.text.Placeholder
57 import androidx.compose.ui.text.TextLayoutInput
58 import androidx.compose.ui.text.TextLayoutResult
59 import androidx.compose.ui.text.TextStyle
60 import androidx.compose.ui.text.font.FontFamily
61 import androidx.compose.ui.text.style.TextDecoration
62 import androidx.compose.ui.text.style.TextOverflow
63 import androidx.compose.ui.unit.Constraints
64 import androidx.compose.ui.unit.Constraints.Companion.fitPrioritizingWidth
65 import androidx.compose.ui.unit.Density
66 import androidx.compose.ui.util.fastRoundToInt
67 import androidx.compose.ui.util.trace
68
69 /** Node that implements Text for [AnnotatedString] or [onTextLayout] parameters. */
70 internal class TextAnnotatedStringNode(
71 private var text: AnnotatedString,
72 private var style: TextStyle,
73 private var fontFamilyResolver: FontFamily.Resolver,
74 private var onTextLayout: ((TextLayoutResult) -> Unit)? = null,
75 private var overflow: TextOverflow = TextOverflow.Clip,
76 private var softWrap: Boolean = true,
77 private var maxLines: Int = Int.MAX_VALUE,
78 private var minLines: Int = DefaultMinLines,
79 private var placeholders: List<AnnotatedString.Range<Placeholder>>? = null,
80 private var onPlaceholderLayout: ((List<Rect?>) -> Unit)? = null,
81 private var selectionController: SelectionController? = null,
82 private var overrideColor: ColorProducer? = null,
83 private var autoSize: TextAutoSize? = null,
84 private var onShowTranslation: ((TextSubstitutionValue) -> Unit)? = null
85 ) : Modifier.Node(), LayoutModifierNode, DrawModifierNode, SemanticsModifierNode {
86 override val shouldAutoInvalidate: Boolean
87 get() = false
88
89 @Suppress("PrimitiveInCollection")
90 private var baselineCache: MutableMap<AlignmentLine, Int>? = null
91
92 private var _layoutCache: MultiParagraphLayoutCache? = null
93 private val layoutCache: MultiParagraphLayoutCache
94 get() {
95 if (_layoutCache == null) {
96 _layoutCache =
97 MultiParagraphLayoutCache(
98 text,
99 style,
100 fontFamilyResolver,
101 overflow,
102 softWrap,
103 maxLines,
104 minLines,
105 placeholders,
106 autoSize
107 )
108 }
109 return _layoutCache!!
110 }
111
112 /**
113 * Get the layout cache for the current state of the node.
114 *
115 * If text substitution is active, this will return the layout cache for the substitution.
116 * Otherwise, it will return the layout cache for the original text.
117 */
118 private fun getLayoutCache(density: Density): MultiParagraphLayoutCache {
119 textSubstitution?.let { textSubstitutionValue ->
120 if (textSubstitutionValue.isShowingSubstitution) {
121 textSubstitutionValue.layoutCache?.let { cache ->
122 return cache.also { it.density = density }
123 }
124 }
125 }
126 return layoutCache.also { it.density = density }
127 }
128
129 /** Element has draw parameters to update */
130 fun updateDraw(color: ColorProducer?, style: TextStyle): Boolean {
131 var changed = false
132 if (color != this.overrideColor) {
133 changed = true
134 }
135 overrideColor = color
136 changed = changed || !style.hasSameDrawAffectingAttributes(this.style)
137 return changed
138 }
139
140 /** Element has text parameters to update */
141 internal fun updateText(text: AnnotatedString): Boolean {
142 val charDiff = this.text.text != text.text
143 val annotationDiff = !this.text.hasEqualAnnotations(text)
144 val anyDiff = charDiff || annotationDiff
145
146 if (anyDiff) {
147 this.text = text
148 }
149 if (charDiff) {
150 clearSubstitution()
151 }
152 return anyDiff
153 }
154
155 /** Element has layout parameters to update */
156 fun updateLayoutRelatedArgs(
157 style: TextStyle,
158 placeholders: List<AnnotatedString.Range<Placeholder>>?,
159 minLines: Int,
160 maxLines: Int,
161 softWrap: Boolean,
162 fontFamilyResolver: FontFamily.Resolver,
163 overflow: TextOverflow,
164 autoSize: TextAutoSize?
165 ): Boolean {
166 var changed: Boolean
167
168 changed = !this.style.hasSameLayoutAffectingAttributes(style)
169 this.style = style
170
171 if (this.placeholders != placeholders) {
172 this.placeholders = placeholders
173 changed = true
174 }
175
176 if (this.minLines != minLines) {
177 this.minLines = minLines
178 changed = true
179 }
180
181 if (this.maxLines != maxLines) {
182 this.maxLines = maxLines
183 changed = true
184 }
185
186 if (this.softWrap != softWrap) {
187 this.softWrap = softWrap
188 changed = true
189 }
190
191 if (this.fontFamilyResolver != fontFamilyResolver) {
192 this.fontFamilyResolver = fontFamilyResolver
193 changed = true
194 }
195
196 if (this.overflow != overflow) {
197 this.overflow = overflow
198 changed = true
199 }
200
201 if (this.autoSize != autoSize) {
202 this.autoSize = autoSize
203 changed = true
204 }
205
206 return changed
207 }
208
209 /** Element has callback parameters to update */
210 fun updateCallbacks(
211 onTextLayout: ((TextLayoutResult) -> Unit)?,
212 onPlaceholderLayout: ((List<Rect?>) -> Unit)?,
213 selectionController: SelectionController?,
214 onShowTranslation: ((TextSubstitutionValue) -> Unit)?
215 ): Boolean {
216 var changed = false
217
218 if (this.onTextLayout !== onTextLayout) {
219 this.onTextLayout = onTextLayout
220 changed = true
221 }
222
223 if (this.onPlaceholderLayout !== onPlaceholderLayout) {
224 this.onPlaceholderLayout = onPlaceholderLayout
225 changed = true
226 }
227
228 if (this.selectionController != selectionController) {
229 this.selectionController = selectionController
230 changed = true
231 }
232
233 if (this.onShowTranslation !== onShowTranslation) {
234 this.onShowTranslation = onShowTranslation
235 changed = true
236 }
237 return changed
238 }
239
240 /** Do appropriate invalidate calls based on the results of update above. */
241 fun doInvalidations(
242 drawChanged: Boolean,
243 textChanged: Boolean,
244 layoutChanged: Boolean,
245 callbacksChanged: Boolean
246 ) {
247 // bring caches up to date even if the node is detached in case it is used again later
248 if (textChanged || layoutChanged || callbacksChanged) {
249 layoutCache.update(
250 text = text,
251 style = style,
252 fontFamilyResolver = fontFamilyResolver,
253 overflow = overflow,
254 softWrap = softWrap,
255 maxLines = maxLines,
256 minLines = minLines,
257 placeholders = placeholders,
258 autoSize = autoSize
259 )
260 }
261
262 if (!isAttached) {
263 // no-up for !isAttached. The node will invalidate when attaching again.
264 return
265 }
266 if (textChanged || (drawChanged && semanticsTextLayoutResult != null)) {
267 invalidateSemantics()
268 }
269
270 if (textChanged || layoutChanged || callbacksChanged) {
271 invalidateMeasurement()
272 invalidateDraw()
273 }
274 if (drawChanged) {
275 invalidateDraw()
276 }
277 }
278
279 private var semanticsTextLayoutResult: ((MutableList<TextLayoutResult>) -> Boolean)? = null
280
281 data class TextSubstitutionValue(
282 val original: AnnotatedString,
283 var substitution: AnnotatedString,
284 var isShowingSubstitution: Boolean = false,
285 var layoutCache: MultiParagraphLayoutCache? = null,
286 // TODO(b/283944749): add animation
287 )
288
289 internal var textSubstitution: TextSubstitutionValue? = null
290
291 private fun setSubstitution(updatedText: AnnotatedString): Boolean {
292 val currentTextSubstitution = textSubstitution
293 // updatedText is currently always returned as a plain text so we ignore inline content
294 // which is represented by placeholders. If translation ever supports annotations, we
295 // would need to recalculate the placeholders
296 if (currentTextSubstitution != null) {
297 if (updatedText == currentTextSubstitution.substitution) {
298 return false
299 }
300 currentTextSubstitution.substitution = updatedText
301 currentTextSubstitution.layoutCache?.update(
302 updatedText,
303 style,
304 fontFamilyResolver,
305 overflow,
306 softWrap,
307 maxLines,
308 minLines,
309 placeholders = emptyList(),
310 autoSize
311 ) ?: return false
312 } else {
313 val newTextSubstitution = TextSubstitutionValue(text, updatedText)
314 val substitutionLayoutCache =
315 MultiParagraphLayoutCache(
316 updatedText,
317 style,
318 fontFamilyResolver,
319 overflow,
320 softWrap,
321 maxLines,
322 minLines,
323 placeholders = emptyList(),
324 autoSize
325 )
326 substitutionLayoutCache.density = layoutCache.density
327 newTextSubstitution.layoutCache = substitutionLayoutCache
328 textSubstitution = newTextSubstitution
329 }
330 return true
331 }
332
333 /** Call whenever text substitution changes state */
334 private fun invalidateForTranslate() {
335 invalidateSemantics()
336 invalidateMeasurement()
337 invalidateDraw()
338 }
339
340 internal fun clearSubstitution() {
341 textSubstitution = null
342 }
343
344 override fun SemanticsPropertyReceiver.applySemantics() {
345 var localSemanticsTextLayoutResult = semanticsTextLayoutResult
346 if (localSemanticsTextLayoutResult == null) {
347 localSemanticsTextLayoutResult = { textLayoutResult ->
348 val inputLayout = layoutCache.layoutOrNull
349 val layout =
350 inputLayout
351 ?.copy(
352 layoutInput =
353 TextLayoutInput(
354 text = inputLayout.layoutInput.text,
355 style =
356 this@TextAnnotatedStringNode.style.merge(
357 color = overrideColor?.invoke() ?: Color.Unspecified
358 ),
359 placeholders = inputLayout.layoutInput.placeholders,
360 maxLines = inputLayout.layoutInput.maxLines,
361 softWrap = inputLayout.layoutInput.softWrap,
362 overflow = inputLayout.layoutInput.overflow,
363 density = inputLayout.layoutInput.density,
364 layoutDirection = inputLayout.layoutInput.layoutDirection,
365 fontFamilyResolver = inputLayout.layoutInput.fontFamilyResolver,
366 constraints = inputLayout.layoutInput.constraints
367 )
368 )
369 ?.also { textLayoutResult.add(it) }
370 layout != null
371 }
372 semanticsTextLayoutResult = localSemanticsTextLayoutResult
373 }
374
375 text = this@TextAnnotatedStringNode.text
376 val currentTextSubstitution = this@TextAnnotatedStringNode.textSubstitution
377 if (currentTextSubstitution != null) {
378 textSubstitution = currentTextSubstitution.substitution
379 isShowingTextSubstitution = currentTextSubstitution.isShowingSubstitution
380 }
381
382 setTextSubstitution { updatedText ->
383 setSubstitution(updatedText)
384
385 invalidateForTranslate()
386
387 true
388 }
389 showTextSubstitution {
390 if (this@TextAnnotatedStringNode.textSubstitution == null) {
391 return@showTextSubstitution false
392 }
393 onShowTranslation?.invoke(this@TextAnnotatedStringNode.textSubstitution!!)
394
395 this@TextAnnotatedStringNode.textSubstitution?.isShowingSubstitution = it
396
397 invalidateForTranslate()
398
399 true
400 }
401 clearTextSubstitution {
402 clearSubstitution()
403
404 invalidateForTranslate()
405
406 true
407 }
408 getTextLayoutResult(action = localSemanticsTextLayoutResult)
409 }
410
411 fun measureNonExtension(
412 measureScope: MeasureScope,
413 measurable: Measurable,
414 constraints: Constraints
415 ): MeasureResult {
416 return measureScope.measure(measurable, constraints)
417 }
418
419 /** Text layout is performed here. */
420 override fun MeasureScope.measure(
421 measurable: Measurable,
422 constraints: Constraints
423 ): MeasureResult {
424 trace("TextAnnotatedStringNode:measure") {
425 val layoutCache = getLayoutCache(this)
426
427 val didChangeLayout = layoutCache.layoutWithConstraints(constraints, layoutDirection)
428 val textLayoutResult = layoutCache.textLayoutResult
429
430 // ensure measure restarts when hasStaleResolvedFonts by reading in measure
431 textLayoutResult.multiParagraph.intrinsics.hasStaleResolvedFonts
432
433 if (didChangeLayout) {
434 invalidateLayer()
435 onTextLayout?.invoke(textLayoutResult)
436 selectionController?.updateTextLayout(textLayoutResult)
437
438 @Suppress("PrimitiveInCollection") val cache = baselineCache ?: LinkedHashMap(2)
439 cache[FirstBaseline] = textLayoutResult.firstBaseline.fastRoundToInt()
440 cache[LastBaseline] = textLayoutResult.lastBaseline.fastRoundToInt()
441 baselineCache = cache
442 }
443
444 // first share the placeholders
445 onPlaceholderLayout?.invoke(textLayoutResult.placeholderRects)
446
447 // then allow children to measure _inside_ our final box, with the above placeholders
448 val placeable =
449 measurable.measure(
450 fitPrioritizingWidth(
451 minWidth = textLayoutResult.size.width,
452 maxWidth = textLayoutResult.size.width,
453 minHeight = textLayoutResult.size.height,
454 maxHeight = textLayoutResult.size.height
455 )
456 )
457
458 return layout(
459 textLayoutResult.size.width,
460 textLayoutResult.size.height,
461 baselineCache!!
462 ) {
463 placeable.place(0, 0)
464 }
465 }
466 }
467
468 fun minIntrinsicWidthNonExtension(
469 intrinsicMeasureScope: IntrinsicMeasureScope,
470 measurable: IntrinsicMeasurable,
471 height: Int
472 ): Int {
473 return intrinsicMeasureScope.minIntrinsicWidth(measurable, height)
474 }
475
476 override fun IntrinsicMeasureScope.minIntrinsicWidth(
477 measurable: IntrinsicMeasurable,
478 height: Int
479 ): Int {
480 return getLayoutCache(this).minIntrinsicWidth(layoutDirection)
481 }
482
483 fun minIntrinsicHeightNonExtension(
484 intrinsicMeasureScope: IntrinsicMeasureScope,
485 measurable: IntrinsicMeasurable,
486 width: Int
487 ): Int {
488 return intrinsicMeasureScope.minIntrinsicHeight(measurable, width)
489 }
490
491 override fun IntrinsicMeasureScope.minIntrinsicHeight(
492 measurable: IntrinsicMeasurable,
493 width: Int
494 ): Int = getLayoutCache(this).intrinsicHeight(width, layoutDirection)
495
496 fun maxIntrinsicWidthNonExtension(
497 intrinsicMeasureScope: IntrinsicMeasureScope,
498 measurable: IntrinsicMeasurable,
499 height: Int
500 ): Int = intrinsicMeasureScope.maxIntrinsicWidth(measurable, height)
501
502 override fun IntrinsicMeasureScope.maxIntrinsicWidth(
503 measurable: IntrinsicMeasurable,
504 height: Int
505 ): Int = getLayoutCache(this).maxIntrinsicWidth(layoutDirection)
506
507 fun maxIntrinsicHeightNonExtension(
508 intrinsicMeasureScope: IntrinsicMeasureScope,
509 measurable: IntrinsicMeasurable,
510 width: Int
511 ): Int = intrinsicMeasureScope.maxIntrinsicHeight(measurable, width)
512
513 override fun IntrinsicMeasureScope.maxIntrinsicHeight(
514 measurable: IntrinsicMeasurable,
515 width: Int
516 ): Int = getLayoutCache(this).intrinsicHeight(width, layoutDirection)
517
518 fun drawNonExtension(contentDrawScope: ContentDrawScope) {
519 return contentDrawScope.draw()
520 }
521
522 override fun ContentDrawScope.draw() {
523 if (!isAttached) {
524 // no-up for !isAttached. The node will invalidate when attaching again.
525 return
526 }
527
528 selectionController?.draw(this)
529 drawIntoCanvas { canvas ->
530 val layoutCache = getLayoutCache(this)
531 val textLayoutResult = layoutCache.textLayoutResult
532 val localParagraph = textLayoutResult.multiParagraph
533 val willClip = textLayoutResult.hasVisualOverflow && overflow != TextOverflow.Visible
534 if (willClip) {
535 val width = textLayoutResult.size.width.toFloat()
536 val height = textLayoutResult.size.height.toFloat()
537 val bounds = Rect(Offset.Zero, Size(width, height))
538 canvas.save()
539 canvas.clipRect(bounds)
540 }
541 try {
542 val textDecoration = style.textDecoration ?: TextDecoration.None
543 val shadow = style.shadow ?: Shadow.None
544 val drawStyle = style.drawStyle ?: Fill
545 val brush = style.brush
546 if (brush != null) {
547 val alpha = style.alpha
548 localParagraph.paint(
549 canvas = canvas,
550 brush = brush,
551 alpha = alpha,
552 shadow = shadow,
553 drawStyle = drawStyle,
554 decoration = textDecoration
555 )
556 } else {
557 val overrideColorVal = overrideColor?.invoke() ?: Color.Unspecified
558 val color =
559 if (overrideColorVal.isSpecified) {
560 overrideColorVal
561 } else if (style.color.isSpecified) {
562 style.color
563 } else {
564 Color.Black
565 }
566 localParagraph.paint(
567 canvas = canvas,
568 color = color,
569 shadow = shadow,
570 drawStyle = drawStyle,
571 decoration = textDecoration
572 )
573 }
574 } finally {
575 if (willClip) {
576 canvas.restore()
577 }
578 }
579
580 // draw inline content and links indication
581 val hasLinks =
582 if (textSubstitution?.isShowingSubstitution == true) {
583 false
584 } else {
585 text.hasLinks()
586 }
587 if (hasLinks || !placeholders.isNullOrEmpty()) {
588 drawContent()
589 }
590 }
591 }
592 }
593
hasLinksnull594 internal fun AnnotatedString.hasLinks() = hasLinkAnnotations(0, length)
595