1 /* 2 * Copyright 2019 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.ui.text 18 19 import androidx.collection.LruCache 20 import androidx.compose.runtime.Immutable 21 import androidx.compose.runtime.Stable 22 import androidx.compose.ui.text.font.FontFamily 23 import androidx.compose.ui.text.style.TextOverflow 24 import androidx.compose.ui.unit.Constraints 25 import androidx.compose.ui.unit.Density 26 import androidx.compose.ui.unit.IntSize 27 import androidx.compose.ui.unit.LayoutDirection 28 import androidx.compose.ui.unit.constrain 29 import kotlin.math.ceil 30 31 /** 32 * Use cases that converge to this number; 33 * - Static text is drawn on canvas for legend and labels. 34 * - Text toggles between enumerated states bold, italic. 35 * - Multiple texts drawn but only their colors are animated. 36 * 37 * If text layout is always called with different inputs, this number is a good stopping point so 38 * that cache does not becomes unnecessarily large and miss penalty stays low. Of course developers 39 * should be aware that in a use case like that the cache should explicitly be disabled. 40 */ 41 private const val DefaultCacheSize = 8 42 43 /** 44 * TextMeasurer is responsible for measuring a text in its entirety so that it's ready to be drawn. 45 * 46 * A TextMeasurer instance should be created via `androidx.compose.ui.rememberTextMeasurer` in a 47 * Composable context to use fallback values from default composition locals. 48 * 49 * Text layout is a computationally expensive task. Therefore, this class holds an internal LRU 50 * Cache of layout input and output pairs to optimize the repeated measure calls that use the same 51 * input parameters. 52 * 53 * Although most input parameters have a direct influence on layout, some parameters like color, 54 * brush, and shadow can be ignored during layout and set at the end. Using TextMeasurer with 55 * appropriate [cacheSize] should provide significant improvements while animating 56 * non-layout-affecting attributes like color. 57 * 58 * Moreover, if there is a need to render multiple static texts, you can provide the number of texts 59 * by [cacheSize] and their layouts should be cached for repeating calls. Be careful that even a 60 * slight change in input parameters like fontSize, maxLines, an additional character in text would 61 * create a distinct set of input parameters. As a result, a new layout would be calculated and a 62 * new set of input and output pair would be placed in LRU Cache, possibly evicting an earlier 63 * result. 64 * 65 * [FontFamily.Resolver], [LayoutDirection], and [Density] are required parameters to construct a 66 * text layout but they have no safe fallbacks outside of composition. These parameters must be 67 * provided during the construction of a TextMeasurer to be used as default values when they are 68 * skipped in [TextMeasurer.measure] call. 69 * 70 * @param defaultFontFamilyResolver to be used to load fonts given in [TextStyle] and [SpanStyle]s 71 * in [AnnotatedString]. 72 * @param defaultLayoutDirection layout direction of the measurement environment. 73 * @param defaultDensity density of the measurement environment. Density controls the scaling factor 74 * for fonts. 75 * @param cacheSize Capacity of internal cache inside TextMeasurer. Size unit is the number of 76 * unique text layout inputs that are measured. Value of this parameter highly depends on the 77 * consumer use case. Provide a cache size that is in line with how many distinct text layouts are 78 * going to be calculated by this measurer repeatedly. If you are animating font attributes, or 79 * any other layout affecting input, cache can be skipped because most repeated measure calls 80 * would miss the cache. 81 */ 82 @Immutable 83 class TextMeasurer( 84 private val defaultFontFamilyResolver: FontFamily.Resolver, 85 private val defaultDensity: Density, 86 private val defaultLayoutDirection: LayoutDirection, 87 private val cacheSize: Int = DefaultCacheSize 88 ) { 89 private val textLayoutCache: TextLayoutCache? = 90 if (cacheSize > 0) { 91 TextLayoutCache(cacheSize) 92 } else null 93 94 /** 95 * Creates a [TextLayoutResult] according to given parameters. 96 * 97 * This function supports laying out text that consists of multiple paragraphs, includes 98 * placeholders, wraps around soft line breaks, and might overflow outside the specified size. 99 * 100 * Most parameters for text affect the final text layout. One pixel change in [constraints] 101 * boundaries can displace a word to another line which would cause a chain reaction that 102 * completely changes how text is rendered. 103 * 104 * On the other hand, some attributes only play a role when drawing the created text layout. For 105 * example text layout can be created completely in black color but we can apply 106 * [TextStyle.color] later in draw phase. This also means that animating text color shouldn't 107 * invalidate text layout. 108 * 109 * Thus, [textLayoutCache] helps in the process of converting a set of text layout inputs to a 110 * text layout while ignoring non-layout-affecting attributes. Iterative calls that use the same 111 * input parameters should benefit from substantial performance improvements. 112 * 113 * @param text the text to be laid out 114 * @param style the [TextStyle] to be applied to the whole text 115 * @param overflow How visual overflow should be handled. 116 * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in 117 * the text will be positioned as if there was unlimited horizontal space. If [softWrap] is 118 * false, [overflow] and TextAlign may have unexpected effects. 119 * @param maxLines An optional maximum number of lines for the text to span, wrapping if 120 * necessary. If the text exceeds the given number of lines, it will be truncated according to 121 * [overflow] and [softWrap]. If it is not null, then it must be greater than zero. 122 * @param placeholders a list of [Placeholder]s that specify ranges of text which will be 123 * skipped during layout and replaced with [Placeholder]. It's required that the range of each 124 * [Placeholder] doesn't cross paragraph boundary, otherwise [IllegalArgumentException] is 125 * thrown. 126 * @param constraints how wide and tall the text is allowed to be. [Constraints.maxWidth] will 127 * define the width of the MultiParagraph. [Constraints.maxHeight] helps defining the number 128 * of lines that fit with ellipsis is true. [Constraints.minWidth] defines the minimum width 129 * the resulting [TextLayoutResult.size] will report. [Constraints.minHeight] is no-op. 130 * @param layoutDirection layout direction of the measurement environment. If not specified, 131 * defaults to the value that was given during initialization of this [TextMeasurer]. 132 * @param density density of the measurement environment. If not specified, defaults to the 133 * value that was given during initialization of this [TextMeasurer]. 134 * @param fontFamilyResolver to be used to load the font given in [SpanStyle]s. If not 135 * specified, defaults to the value that was given during initialization of this 136 * [TextMeasurer]. 137 * @param skipCache Disables cache optimization if it is passed as true. 138 * @sample androidx.compose.ui.text.samples.measureTextAnnotatedString 139 */ 140 @Stable measurenull141 fun measure( 142 text: AnnotatedString, 143 style: TextStyle = TextStyle.Default, 144 overflow: TextOverflow = TextOverflow.Clip, 145 softWrap: Boolean = true, 146 maxLines: Int = Int.MAX_VALUE, 147 placeholders: List<AnnotatedString.Range<Placeholder>> = listOf(), 148 constraints: Constraints = Constraints(), 149 layoutDirection: LayoutDirection = this.defaultLayoutDirection, 150 density: Density = this.defaultDensity, 151 fontFamilyResolver: FontFamily.Resolver = this.defaultFontFamilyResolver, 152 skipCache: Boolean = false 153 ): TextLayoutResult { 154 val requestedTextLayoutInput = 155 TextLayoutInput( 156 text, 157 style, 158 placeholders, 159 maxLines, 160 softWrap, 161 overflow, 162 density, 163 layoutDirection, 164 fontFamilyResolver, 165 constraints 166 ) 167 168 val cacheResult = 169 if (!skipCache && textLayoutCache != null) { 170 textLayoutCache.get(requestedTextLayoutInput) 171 } else null 172 173 return if (cacheResult != null) { 174 cacheResult.copy( 175 layoutInput = requestedTextLayoutInput, 176 size = 177 constraints.constrain( 178 IntSize( 179 cacheResult.multiParagraph.width.ceilToInt(), 180 cacheResult.multiParagraph.height.ceilToInt() 181 ) 182 ) 183 ) 184 } else { 185 layout(requestedTextLayoutInput).also { 186 textLayoutCache?.put(requestedTextLayoutInput, it) 187 } 188 } 189 } 190 191 /** 192 * Creates a [TextLayoutResult] according to given parameters. 193 * 194 * This function supports laying out text that consists of multiple paragraphs, includes 195 * placeholders, wraps around soft line breaks, and might overflow outside the specified size. 196 * 197 * Most parameters for text affect the final text layout. One pixel change in [constraints] 198 * boundaries can displace a word to another line which would cause a chain reaction that 199 * completely changes how text is rendered. 200 * 201 * On the other hand, some attributes only play a role when drawing the created text layout. For 202 * example text layout can be created completely in black color but we can apply 203 * [TextStyle.color] later in draw phase. This also means that animating text color shouldn't 204 * invalidate text layout. 205 * 206 * Thus, [textLayoutCache] helps in the process of converting a set of text layout inputs to a 207 * text layout while ignoring non-layout-affecting attributes. Iterative calls that use the same 208 * input parameters should benefit from substantial performance improvements. 209 * 210 * @param text the text to be laid out 211 * @param style the [TextStyle] to be applied to the whole text 212 * @param overflow How visual overflow should be handled. 213 * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in 214 * the text will be positioned as if there was unlimited horizontal space. If [softWrap] is 215 * false, [overflow] and TextAlign may have unexpected effects. 216 * @param maxLines An optional maximum number of lines for the text to span, wrapping if 217 * necessary. If the text exceeds the given number of lines, it will be truncated according to 218 * [overflow] and [softWrap]. If it is not null, then it must be greater than zero. 219 * @param constraints how wide and tall the text is allowed to be. [Constraints.maxWidth] will 220 * define the width of the MultiParagraph. [Constraints.maxHeight] helps defining the number 221 * of lines that fit with ellipsis is true. [Constraints.minWidth] defines the minimum width 222 * the resulting [TextLayoutResult.size] will report. [Constraints.minHeight] is no-op. 223 * @param layoutDirection layout direction of the measurement environment. If not specified, 224 * defaults to the value that was given during initialization of this [TextMeasurer]. 225 * @param density density of the measurement environment. If not specified, defaults to the 226 * value that was given during initialization of this [TextMeasurer]. 227 * @param fontFamilyResolver to be used to load the font given in [SpanStyle]s. If not 228 * specified, defaults to the value that was given during initialization of this 229 * [TextMeasurer]. 230 * @param skipCache Disables cache optimization if it is passed as true. 231 * @sample androidx.compose.ui.text.samples.measureTextStringWithConstraints 232 */ 233 @Stable measurenull234 fun measure( 235 text: String, 236 style: TextStyle = TextStyle.Default, 237 overflow: TextOverflow = TextOverflow.Clip, 238 softWrap: Boolean = true, 239 maxLines: Int = Int.MAX_VALUE, 240 constraints: Constraints = Constraints(), 241 layoutDirection: LayoutDirection = this.defaultLayoutDirection, 242 density: Density = this.defaultDensity, 243 fontFamilyResolver: FontFamily.Resolver = this.defaultFontFamilyResolver, 244 skipCache: Boolean = false 245 ): TextLayoutResult { 246 return measure( 247 text = AnnotatedString(text), 248 style = style, 249 overflow = overflow, 250 softWrap = softWrap, 251 maxLines = maxLines, 252 constraints = constraints, 253 layoutDirection = layoutDirection, 254 density = density, 255 fontFamilyResolver = fontFamilyResolver, 256 skipCache = skipCache 257 ) 258 } 259 260 internal companion object { 261 /** 262 * Computes the visual position of the glyphs for painting the text. 263 * 264 * The text will layout with a width that's as close to its max intrinsic width as possible 265 * while still being greater than or equal to `minWidth` and less than or equal to 266 * `maxWidth`. 267 */ layoutnull268 private fun layout(textLayoutInput: TextLayoutInput): TextLayoutResult = 269 with(textLayoutInput) { 270 val nonNullIntrinsics = 271 MultiParagraphIntrinsics( 272 annotatedString = text, 273 style = resolveDefaults(style, layoutDirection), 274 density = density, 275 fontFamilyResolver = fontFamilyResolver, 276 placeholders = placeholders 277 ) 278 279 val minWidth = constraints.minWidth 280 val widthMatters = softWrap || overflow.isEllipsis 281 val maxWidth = 282 if (widthMatters && constraints.hasBoundedWidth) { 283 constraints.maxWidth 284 } else { 285 Constraints.Infinity 286 } 287 288 // This is a fallback behavior because native text layout doesn't support multiple 289 // ellipsis in one text layout. 290 // When softWrap is turned off and overflow is ellipsis, it's expected that each 291 // line 292 // that exceeds maxWidth will be ellipsized. 293 // For example, 294 // input text: 295 // "AAAA\nAAAA" 296 // maxWidth: 297 // 3 * fontSize that only allow 3 characters to be displayed each line. 298 // expected output: 299 // AA… 300 // AA… 301 // Here we assume there won't be any '\n' character when softWrap is false. And make 302 // maxLines 1 to implement the similar behavior. 303 val overwriteMaxLines = !softWrap && overflow.isEllipsis 304 val finalMaxLines = if (overwriteMaxLines) 1 else maxLines 305 306 // if minWidth == maxWidth the width is fixed. 307 // therefore we can pass that value to our paragraph and use it 308 // if minWidth != maxWidth there is a range 309 // then we should check if the max intrinsic width is in this range to decide the 310 // width to be passed to Paragraph 311 // if max intrinsic width is between minWidth and maxWidth 312 // we can use it to layout 313 // else if max intrinsic width is greater than maxWidth, we can only use 314 // maxWidth 315 // else if max intrinsic width is less than minWidth, we should use minWidth 316 val width = 317 if (minWidth == maxWidth) { 318 maxWidth 319 } else { 320 nonNullIntrinsics.maxIntrinsicWidth.ceilToInt().coerceIn(minWidth, maxWidth) 321 } 322 323 val multiParagraph = 324 MultiParagraph( 325 intrinsics = nonNullIntrinsics, 326 constraints = 327 Constraints.fitPrioritizingWidth( 328 minWidth = 0, 329 maxWidth = width, 330 minHeight = 0, 331 maxHeight = constraints.maxHeight 332 ), 333 // This is a fallback behavior for ellipsis. Native 334 maxLines = finalMaxLines, 335 overflow = overflow 336 ) 337 338 return TextLayoutResult( 339 layoutInput = textLayoutInput, 340 multiParagraph = multiParagraph, 341 size = 342 constraints.constrain( 343 IntSize( 344 ceil(multiParagraph.width).toInt(), 345 ceil(multiParagraph.height).toInt() 346 ) 347 ) 348 ) 349 } 350 } 351 } 352 353 /** 354 * Keeps a layout cache of TextLayoutInput, TextLayoutResult pairs. Any non-layout affecting change 355 * in TextLayoutInput (color, brush, shadow, TextDecoration) is ignored by this cache. 356 * 357 * @param capacity Maximum size of the cache. Size unit is the number of [CacheTextLayoutInput] and 358 * [TextLayoutResult] pairs. 359 * @throws IllegalArgumentException if capacity is not a positive integer. 360 */ 361 internal class TextLayoutCache(capacity: Int = DefaultCacheSize) { 362 // Do not allocate an LRU cache if the size is just 1. 363 private val cache: LruCache<CacheTextLayoutInput, TextLayoutResult>? = 364 if (capacity != 1) { 365 // 0 or negative cache size is also handled by LruCache. 366 LruCache(capacity) 367 } else { 368 null 369 } 370 371 private var singleSizeCacheInput: CacheTextLayoutInput? = null 372 private var singleSizeCacheResult: TextLayoutResult? = null 373 getnull374 fun get(key: TextLayoutInput): TextLayoutResult? { 375 val cacheKey = CacheTextLayoutInput(key) 376 val resultFromCache = 377 if (cache != null) { 378 cache[cacheKey] 379 } else if (singleSizeCacheInput == cacheKey) { 380 singleSizeCacheResult 381 } else { 382 return null 383 } 384 385 if (resultFromCache == null) return null 386 387 if (resultFromCache.multiParagraph.intrinsics.hasStaleResolvedFonts) { 388 // one of the resolved fonts has updated, and this MeasuredText is no longer valid for 389 // measure or display 390 return null 391 } 392 393 return resultFromCache 394 } 395 putnull396 fun put(key: TextLayoutInput, value: TextLayoutResult) { 397 if (cache != null) { 398 cache.put(CacheTextLayoutInput(key), value) 399 } else { 400 singleSizeCacheInput = CacheTextLayoutInput(key) 401 singleSizeCacheResult = value 402 } 403 } 404 } 405 406 /** 407 * Provides custom hashCode and equals function that are only interested in layout affecting 408 * attributes in TextLayoutInput. Used as a key in [TextLayoutCache]. 409 */ 410 @Immutable 411 internal class CacheTextLayoutInput(val textLayoutInput: TextLayoutInput) { 412 hashCodenull413 override fun hashCode(): Int = 414 with(textLayoutInput) { 415 var result = text.hashCode() 416 result = 31 * result + style.hashCodeLayoutAffectingAttributes() 417 result = 31 * result + placeholders.hashCode() 418 result = 31 * result + maxLines 419 result = 31 * result + softWrap.hashCode() 420 result = 31 * result + overflow.hashCode() 421 result = 31 * result + density.hashCode() 422 result = 31 * result + layoutDirection.hashCode() 423 result = 31 * result + fontFamilyResolver.hashCode() 424 result = 31 * result + constraints.hashCode() 425 return result 426 } 427 equalsnull428 override fun equals(other: Any?): Boolean { 429 if (this === other) return true 430 if (other !is CacheTextLayoutInput) return false 431 432 with(textLayoutInput) { 433 if (text != other.textLayoutInput.text) return false 434 if (!style.hasSameLayoutAffectingAttributes(other.textLayoutInput.style)) return false 435 if (placeholders != other.textLayoutInput.placeholders) return false 436 if (maxLines != other.textLayoutInput.maxLines) return false 437 if (softWrap != other.textLayoutInput.softWrap) return false 438 if (overflow != other.textLayoutInput.overflow) return false 439 if (density != other.textLayoutInput.density) return false 440 if (layoutDirection != other.textLayoutInput.layoutDirection) return false 441 if (fontFamilyResolver !== other.textLayoutInput.fontFamilyResolver) return false 442 if (constraints != other.textLayoutInput.constraints) return false 443 } 444 445 return true 446 } 447 } 448 449 private val TextOverflow.isEllipsis: Boolean 450 get() { 451 return this == TextOverflow.Ellipsis || 452 this == TextOverflow.StartEllipsis || 453 this == TextOverflow.MiddleEllipsis 454 } 455