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.internal.requirePreconditionNotNull 20 import androidx.compose.foundation.text.DefaultMinLines 21 import androidx.compose.ui.Modifier 22 import androidx.compose.ui.graphics.Color 23 import androidx.compose.ui.graphics.ColorProducer 24 import androidx.compose.ui.graphics.Shadow 25 import androidx.compose.ui.graphics.drawscope.ContentDrawScope 26 import androidx.compose.ui.graphics.drawscope.Fill 27 import androidx.compose.ui.graphics.drawscope.drawIntoCanvas 28 import androidx.compose.ui.graphics.isSpecified 29 import androidx.compose.ui.layout.AlignmentLine 30 import androidx.compose.ui.layout.FirstBaseline 31 import androidx.compose.ui.layout.IntrinsicMeasurable 32 import androidx.compose.ui.layout.IntrinsicMeasureScope 33 import androidx.compose.ui.layout.LastBaseline 34 import androidx.compose.ui.layout.Measurable 35 import androidx.compose.ui.layout.MeasureResult 36 import androidx.compose.ui.layout.MeasureScope 37 import androidx.compose.ui.node.DrawModifierNode 38 import androidx.compose.ui.node.LayoutModifierNode 39 import androidx.compose.ui.node.SemanticsModifierNode 40 import androidx.compose.ui.node.invalidateDraw 41 import androidx.compose.ui.node.invalidateLayer 42 import androidx.compose.ui.node.invalidateMeasurement 43 import androidx.compose.ui.node.invalidateSemantics 44 import androidx.compose.ui.semantics.SemanticsPropertyReceiver 45 import androidx.compose.ui.semantics.clearTextSubstitution 46 import androidx.compose.ui.semantics.getTextLayoutResult 47 import androidx.compose.ui.semantics.isShowingTextSubstitution 48 import androidx.compose.ui.semantics.setTextSubstitution 49 import androidx.compose.ui.semantics.showTextSubstitution 50 import androidx.compose.ui.semantics.text 51 import androidx.compose.ui.semantics.textSubstitution 52 import androidx.compose.ui.text.AnnotatedString 53 import androidx.compose.ui.text.TextLayoutResult 54 import androidx.compose.ui.text.TextStyle 55 import androidx.compose.ui.text.font.FontFamily 56 import androidx.compose.ui.text.style.TextDecoration 57 import androidx.compose.ui.text.style.TextOverflow 58 import androidx.compose.ui.unit.Constraints 59 import androidx.compose.ui.unit.Constraints.Companion.fitPrioritizingWidth 60 import androidx.compose.ui.util.fastRoundToInt 61 import androidx.compose.ui.util.trace 62 import kotlin.jvm.JvmName 63 64 /** 65 * Node that implements Text for [String]. 66 * 67 * It has reduced functionality, and as a result gains in performance. 68 * 69 * Note that this Node never calculates [TextLayoutResult] unless needed by semantics. 70 */ 71 internal class TextStringSimpleNode( 72 private var text: String, 73 private var style: TextStyle, 74 private var fontFamilyResolver: FontFamily.Resolver, 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 overrideColor: ColorProducer? = null 80 ) : Modifier.Node(), LayoutModifierNode, DrawModifierNode, SemanticsModifierNode { 81 override val shouldAutoInvalidate: Boolean 82 get() = false 83 84 @Suppress("PrimitiveInCollection") // Map required for use in public API. 85 // Usages of this collection are so few that the gains of using 86 // MutableObjectIntMap<AlignmentLine> and then converting to a Map<AlignmentLine, Int> 87 // as needed for the public API is not worth the performance benefit. 88 private var baselineCache: MutableMap<AlignmentLine, Int>? = null 89 90 private var _layoutCache: ParagraphLayoutCache? = null 91 private val layoutCache: ParagraphLayoutCache 92 get() { 93 if (_layoutCache == null) { 94 _layoutCache = 95 ParagraphLayoutCache( 96 text, 97 style, 98 fontFamilyResolver, 99 overflow, 100 softWrap, 101 maxLines, 102 minLines 103 ) 104 } 105 return _layoutCache!! 106 } 107 108 /** 109 * Get the layout cache for the current state of the node during layout. 110 * 111 * If text substitution is active, this will return the layout cache for the substitution. 112 * Otherwise, it will return the layout cache for the original text. 113 * 114 * @receiver Current measure scope that requests the layout cache. This scope is used to update 115 * the density value of the returned cache. 116 */ 117 private fun IntrinsicMeasureScope.getLayoutCacheForMeasure(): ParagraphLayoutCache { 118 val activeCache = getLayoutCache() 119 activeCache.density = this@getLayoutCacheForMeasure 120 return activeCache 121 } 122 123 /** 124 * Get the layout cache for the current state of the node without updating the density. 125 * 126 * Warning; DO NOT USE this function from a MeasureScope. Instead please use 127 * [getLayoutCacheForMeasure]. 128 * 129 * The reason this function does not update the density value is because the density should not 130 * change between layout and draw phases. This is a micro optimization to skip the unnecessary 131 * density comparison. 132 * 133 * If text substitution is active, this will return the layout cache for the substitution. 134 * Otherwise, it will return the layout cache for the original text. 135 */ 136 @JvmName("getLayoutCacheOrSubstitute") 137 private fun getLayoutCache(): ParagraphLayoutCache { 138 return textSubstitution?.takeIf { it.isShowingSubstitution }?.layoutCache ?: layoutCache 139 } 140 141 fun updateDraw(color: ColorProducer?, style: TextStyle): Boolean { 142 var changed = false 143 if (color != this.overrideColor) { 144 changed = true 145 } 146 overrideColor = color 147 changed = changed || !style.hasSameDrawAffectingAttributes(this.style) 148 return changed 149 } 150 151 /** Element has text params to update */ 152 fun updateText(text: String): Boolean { 153 if (this.text == text) return false 154 this.text = text 155 clearSubstitution() 156 return true 157 } 158 159 /** Element has layout related params to update */ 160 fun updateLayoutRelatedArgs( 161 style: TextStyle, 162 minLines: Int, 163 maxLines: Int, 164 softWrap: Boolean, 165 fontFamilyResolver: FontFamily.Resolver, 166 overflow: TextOverflow 167 ): Boolean { 168 var changed: Boolean 169 170 changed = !this.style.hasSameLayoutAffectingAttributes(style) 171 this.style = style 172 173 if (this.minLines != minLines) { 174 this.minLines = minLines 175 changed = true 176 } 177 178 if (this.maxLines != maxLines) { 179 this.maxLines = maxLines 180 changed = true 181 } 182 183 if (this.softWrap != softWrap) { 184 this.softWrap = softWrap 185 changed = true 186 } 187 188 if (this.fontFamilyResolver != fontFamilyResolver) { 189 this.fontFamilyResolver = fontFamilyResolver 190 changed = true 191 } 192 193 if (this.overflow != overflow) { 194 this.overflow = overflow 195 changed = true 196 } 197 198 return changed 199 } 200 201 /** request invalidate based on the results of [updateText] and [updateLayoutRelatedArgs] */ 202 fun doInvalidations(drawChanged: Boolean, textChanged: Boolean, layoutChanged: Boolean) { 203 // bring caches up to date even if the node is detached in case it is used again later 204 if (textChanged || layoutChanged) { 205 layoutCache.update( 206 text = text, 207 style = style, 208 fontFamilyResolver = fontFamilyResolver, 209 overflow = overflow, 210 softWrap = softWrap, 211 maxLines = maxLines, 212 minLines = minLines 213 ) 214 } 215 216 if (!isAttached) { 217 // no-up for !isAttached. The node will invalidate when attaching again. 218 return 219 } 220 if (textChanged || (drawChanged && semanticsTextLayoutResult != null)) { 221 invalidateSemantics() 222 } 223 224 if (textChanged || layoutChanged) { 225 invalidateMeasurement() 226 invalidateDraw() 227 } 228 if (drawChanged) { 229 invalidateDraw() 230 } 231 } 232 233 private var semanticsTextLayoutResult: ((MutableList<TextLayoutResult>) -> Boolean)? = null 234 235 data class TextSubstitutionValue( 236 val original: String, 237 var substitution: String, 238 var isShowingSubstitution: Boolean = false, 239 var layoutCache: ParagraphLayoutCache? = null, 240 // TODO(b/283944749): add animation 241 242 ) { 243 // don't emit any user strings in toString 244 override fun toString(): String = 245 "TextSubstitution(" + 246 "layoutCache=$layoutCache, isShowingSubstitution=$isShowingSubstitution" + 247 ")" 248 } 249 250 private var textSubstitution: TextSubstitutionValue? = null 251 252 private fun setSubstitution(updatedText: String): Boolean { 253 val currentTextSubstitution = textSubstitution 254 if (currentTextSubstitution != null) { 255 if (updatedText == currentTextSubstitution.substitution) { 256 return false 257 } 258 currentTextSubstitution.substitution = updatedText 259 currentTextSubstitution.layoutCache?.update( 260 updatedText, 261 style, 262 fontFamilyResolver, 263 overflow, 264 softWrap, 265 maxLines, 266 minLines 267 ) ?: return false 268 } else { 269 val newTextSubstitution = TextSubstitutionValue(text, updatedText) 270 val substitutionLayoutCache = 271 ParagraphLayoutCache( 272 updatedText, 273 style, 274 fontFamilyResolver, 275 overflow, 276 softWrap, 277 maxLines, 278 minLines 279 ) 280 substitutionLayoutCache.density = layoutCache.density 281 newTextSubstitution.layoutCache = substitutionLayoutCache 282 textSubstitution = newTextSubstitution 283 } 284 return true 285 } 286 287 private fun clearSubstitution() { 288 textSubstitution = null 289 } 290 291 override fun SemanticsPropertyReceiver.applySemantics() { 292 var localSemanticsTextLayoutResult = semanticsTextLayoutResult 293 if (localSemanticsTextLayoutResult == null) { 294 localSemanticsTextLayoutResult = { textLayoutResult -> 295 val layout = 296 layoutCache 297 .slowCreateTextLayoutResultOrNull( 298 style = 299 style.merge(color = overrideColor?.invoke() ?: Color.Unspecified) 300 ) 301 ?.also { textLayoutResult.add(it) } 302 layout != null 303 } 304 semanticsTextLayoutResult = localSemanticsTextLayoutResult 305 } 306 307 text = AnnotatedString(this@TextStringSimpleNode.text) 308 val currentTextSubstitution = this@TextStringSimpleNode.textSubstitution 309 if (currentTextSubstitution != null) { 310 isShowingTextSubstitution = currentTextSubstitution.isShowingSubstitution 311 textSubstitution = AnnotatedString(currentTextSubstitution.substitution) 312 } 313 314 setTextSubstitution { updatedText -> 315 setSubstitution(updatedText.text) 316 317 invalidateForTranslate() 318 319 true 320 } 321 showTextSubstitution { 322 if (this@TextStringSimpleNode.textSubstitution == null) { 323 return@showTextSubstitution false 324 } 325 326 this@TextStringSimpleNode.textSubstitution?.isShowingSubstitution = it 327 328 invalidateForTranslate() 329 330 true 331 } 332 clearTextSubstitution { 333 clearSubstitution() 334 335 invalidateForTranslate() 336 337 true 338 } 339 getTextLayoutResult(action = localSemanticsTextLayoutResult) 340 } 341 342 /** Call whenever text substitution changes state */ 343 private fun invalidateForTranslate() { 344 invalidateSemantics() 345 invalidateMeasurement() 346 invalidateDraw() 347 } 348 349 /** Text layout happens here */ 350 override fun MeasureScope.measure( 351 measurable: Measurable, 352 constraints: Constraints 353 ): MeasureResult { 354 trace("TextStringSimpleNode::measure") { 355 val layoutCache = getLayoutCacheForMeasure() 356 357 val didChangeLayout = layoutCache.layoutWithConstraints(constraints, layoutDirection) 358 // ensure measure restarts when hasStaleResolvedFonts by reading in measure 359 layoutCache.observeFontChanges 360 val paragraph = layoutCache.paragraph!! 361 val layoutSize = layoutCache.layoutSize 362 363 if (didChangeLayout) { 364 invalidateLayer() 365 // Map<AlignmentLine, Int> required for use in public API `layout` below 366 @Suppress("PrimitiveInCollection") var cache = baselineCache 367 if (cache == null) { 368 cache = HashMap(2) 369 baselineCache = cache 370 } 371 cache[FirstBaseline] = paragraph.firstBaseline.fastRoundToInt() 372 cache[LastBaseline] = paragraph.lastBaseline.fastRoundToInt() 373 } 374 375 // then allow children to measure _inside_ our final box, with the above placeholders 376 val placeable = 377 measurable.measure( 378 fitPrioritizingWidth( 379 minWidth = layoutSize.width, 380 maxWidth = layoutSize.width, 381 minHeight = layoutSize.height, 382 maxHeight = layoutSize.height 383 ) 384 ) 385 386 return layout(layoutSize.width, layoutSize.height, baselineCache!!) { 387 placeable.place(0, 0) 388 } 389 } 390 } 391 392 override fun IntrinsicMeasureScope.minIntrinsicWidth( 393 measurable: IntrinsicMeasurable, 394 height: Int 395 ): Int { 396 return getLayoutCacheForMeasure().minIntrinsicWidth(layoutDirection) 397 } 398 399 override fun IntrinsicMeasureScope.minIntrinsicHeight( 400 measurable: IntrinsicMeasurable, 401 width: Int 402 ): Int = getLayoutCacheForMeasure().intrinsicHeight(width, layoutDirection) 403 404 override fun IntrinsicMeasureScope.maxIntrinsicWidth( 405 measurable: IntrinsicMeasurable, 406 height: Int 407 ): Int = getLayoutCacheForMeasure().maxIntrinsicWidth(layoutDirection) 408 409 override fun IntrinsicMeasureScope.maxIntrinsicHeight( 410 measurable: IntrinsicMeasurable, 411 width: Int 412 ): Int = getLayoutCacheForMeasure().intrinsicHeight(width, layoutDirection) 413 414 /** Optimized Text draw. */ 415 override fun ContentDrawScope.draw() { 416 if (!isAttached) { 417 // no-up for !isAttached. The node will invalidate when attaching again. 418 return 419 } 420 421 val layoutCache = getLayoutCache() 422 val localParagraph = 423 requirePreconditionNotNull(layoutCache.paragraph) { 424 "no paragraph (layoutCache=$_layoutCache, textSubstitution=$textSubstitution)" 425 } 426 427 drawIntoCanvas { canvas -> 428 val willClip = layoutCache.didOverflow 429 if (willClip) { 430 val width = layoutCache.layoutSize.width.toFloat() 431 val height = layoutCache.layoutSize.height.toFloat() 432 canvas.save() 433 canvas.clipRect(left = 0f, top = 0f, right = width, bottom = height) 434 } 435 try { 436 val textDecoration = style.textDecoration ?: TextDecoration.None 437 val shadow = style.shadow ?: Shadow.None 438 val drawStyle = style.drawStyle ?: Fill 439 val brush = style.brush 440 if (brush != null) { 441 val alpha = style.alpha 442 localParagraph.paint( 443 canvas = canvas, 444 brush = brush, 445 alpha = alpha, 446 shadow = shadow, 447 drawStyle = drawStyle, 448 textDecoration = textDecoration 449 ) 450 } else { 451 val overrideColorVal = overrideColor?.invoke() ?: Color.Unspecified 452 val color = 453 if (overrideColorVal.isSpecified) { 454 overrideColorVal 455 } else if (style.color.isSpecified) { 456 style.color 457 } else { 458 Color.Black 459 } 460 localParagraph.paint( 461 canvas = canvas, 462 color = color, 463 shadow = shadow, 464 drawStyle = drawStyle, 465 textDecoration = textDecoration 466 ) 467 } 468 } finally { 469 if (willClip) { 470 canvas.restore() 471 } 472 } 473 } 474 } 475 } 476