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