blob: 533d7ebffdbe854165f8fbb29f5b02469aac852b [file] [log] [blame]
/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.ui.text
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.caches.LruCache
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrain
import kotlin.math.ceil
/**
* Use cases that converge to this number;
* - Static text is drawn on canvas for legend and labels.
* - Text toggles between enumerated states bold, italic.
* - Multiple texts drawn but only their colors are animated.
*
* If text layout is always called with different inputs, this number is a good stopping point so
* that cache does not becomes unnecessarily large and miss penalty stays low. Of course developers
* should be aware that in a use case like that the cache should explicitly be disabled.
*/
private val DefaultCacheSize = 8
/**
* TextMeasurer is responsible for measuring a text in its entirety so that it's ready to be drawn.
*
* A TextMeasurer instance should be created via `androidx.compose.ui.rememberTextMeasurer` in a
* Composable context to use fallback values from default composition locals.
*
* Text layout is a computationally expensive task. Therefore, this class holds an internal LRU
* Cache of layout input and output pairs to optimize the repeated measure calls that use the same
* input parameters.
*
* Although most input parameters have a direct influence on layout, some parameters like color,
* brush, and shadow can be ignored during layout and set at the end. Using TextMeasurer with
* appropriate [cacheSize] should provide significant improvements while animating
* non-layout-affecting attributes like color.
*
* Moreover, if there is a need to render multiple static texts, you can provide the number of texts
* by [cacheSize] and their layouts should be cached for repeating calls. Be careful that even a
* slight change in input parameters like fontSize, maxLines, an additional character in text would
* create a distinct set of input parameters. As a result, a new layout would be calculated and a
* new set of input and output pair would be placed in LRU Cache, possibly evicting an earlier
* result.
*
* [FontFamily.Resolver], [LayoutDirection], and [Density] are required parameters to construct a
* text layout but they have no safe fallbacks outside of composition. These parameters must be
* provided during the construction of a TextMeasurer to be used as default values when they
* are skipped in [TextMeasurer.measure] call.
*
* @param defaultFontFamilyResolver to be used to load fonts given in [TextStyle] and [SpanStyle]s
* in [AnnotatedString].
* @param defaultLayoutDirection layout direction of the measurement environment.
* @param defaultDensity density of the measurement environment. Density controls the scaling
* factor for fonts.
* @param cacheSize Capacity of internal cache inside TextMeasurer. Size unit is the number of
* unique text layout inputs that are measured. Value of this parameter highly depends on the
* consumer use case. Provide a cache size that is in line with how many distinct text layouts are
* going to be calculated by this measurer repeatedly. If you are animating font attributes, or any
* other layout affecting input, cache can be skipped because most repeated measure calls would miss
* the cache.
*/
@Immutable
class TextMeasurer constructor(
private val defaultFontFamilyResolver: FontFamily.Resolver,
private val defaultDensity: Density,
private val defaultLayoutDirection: LayoutDirection,
private val cacheSize: Int = DefaultCacheSize
) {
private val textLayoutCache: TextLayoutCache? = if (cacheSize > 0) {
TextLayoutCache(cacheSize)
} else null
/**
* Creates a [TextLayoutResult] according to given parameters.
*
* This function supports laying out text that consists of multiple paragraphs, includes
* placeholders, wraps around soft line breaks, and might overflow outside the specified size.
*
* Most parameters for text affect the final text layout. One pixel change in [constraints]
* boundaries can displace a word to another line which would cause a chain reaction that
* completely changes how text is rendered.
*
* On the other hand, some attributes only play a role when drawing the created text layout.
* For example text layout can be created completely in black color but we can apply
* [TextStyle.color] later in draw phase. This also means that animating text color shouldn't
* invalidate text layout.
*
* Thus, [textLayoutCache] helps in the process of converting a set of text layout inputs to
* a text layout while ignoring non-layout-affecting attributes. Iterative calls that use the
* same input parameters should benefit from substantial performance improvements.
*
* @param text the text to be laid out
* @param style the [TextStyle] to be applied to the whole text
* @param overflow How visual overflow should be handled.
* @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in
* the text will be positioned as if there was unlimited horizontal space. If [softWrap] is
* false, [overflow] and TextAlign may have unexpected effects.
* @param maxLines An optional maximum number of lines for the text to span, wrapping if
* necessary. If the text exceeds the given number of lines, it will be truncated according to
* [overflow] and [softWrap]. If it is not null, then it must be greater than zero.
* @param placeholders a list of [Placeholder]s that specify ranges of text which will be
* skipped during layout and replaced with [Placeholder]. It's required that the range of each
* [Placeholder] doesn't cross paragraph boundary, otherwise [IllegalArgumentException] is
* thrown.
* @param constraints how wide and tall the text is allowed to be. [Constraints.maxWidth]
* will define the width of the MultiParagraph. [Constraints.maxHeight] helps defining the
* number of lines that fit with ellipsis is true. [Constraints.minWidth] defines the minimum
* width the resulting [TextLayoutResult.size] will report. [Constraints.minHeight] is no-op.
* @param layoutDirection layout direction of the measurement environment. If not specified,
* defaults to the value that was given during initialization of this [TextMeasurer].
* @param density density of the measurement environment. If not specified, defaults to
* the value that was given during initialization of this [TextMeasurer].
* @param fontFamilyResolver to be used to load the font given in [SpanStyle]s. If not
* specified, defaults to the value that was given during initialization of this [TextMeasurer].
* @param skipCache Disables cache optimization if it is passed as true.
*
* @sample androidx.compose.ui.text.samples.measureTextAnnotatedString
*/
@Stable
fun measure(
text: AnnotatedString,
style: TextStyle = TextStyle.Default,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
placeholders: List<AnnotatedString.Range<Placeholder>> = emptyList(),
constraints: Constraints = Constraints(),
layoutDirection: LayoutDirection = this.defaultLayoutDirection,
density: Density = this.defaultDensity,
fontFamilyResolver: FontFamily.Resolver = this.defaultFontFamilyResolver,
skipCache: Boolean = false
): TextLayoutResult {
val requestedTextLayoutInput = TextLayoutInput(
text,
style,
placeholders,
maxLines,
softWrap,
overflow,
density,
layoutDirection,
fontFamilyResolver,
constraints
)
val cacheResult = if (!skipCache && textLayoutCache != null) {
textLayoutCache.get(requestedTextLayoutInput)
} else null
return if (cacheResult != null) {
cacheResult.copy(
layoutInput = requestedTextLayoutInput,
size = constraints.constrain(
IntSize(
cacheResult.multiParagraph.width.ceilToInt(),
cacheResult.multiParagraph.height.ceilToInt()
)
)
)
} else {
layout(requestedTextLayoutInput).also {
textLayoutCache?.put(requestedTextLayoutInput, it)
}
}
}
/**
* Creates a [TextLayoutResult] according to given parameters.
*
* This function supports laying out text that consists of multiple paragraphs, includes
* placeholders, wraps around soft line breaks, and might overflow outside the specified size.
*
* Most parameters for text affect the final text layout. One pixel change in [constraints]
* boundaries can displace a word to another line which would cause a chain reaction that
* completely changes how text is rendered.
*
* On the other hand, some attributes only play a role when drawing the created text layout.
* For example text layout can be created completely in black color but we can apply
* [TextStyle.color] later in draw phase. This also means that animating text color shouldn't
* invalidate text layout.
*
* Thus, [textLayoutCache] helps in the process of converting a set of text layout inputs to
* a text layout while ignoring non-layout-affecting attributes. Iterative calls that use the
* same input parameters should benefit from substantial performance improvements.
*
* @param text the text to be laid out
* @param style the [TextStyle] to be applied to the whole text
* @param overflow How visual overflow should be handled.
* @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in
* the text will be positioned as if there was unlimited horizontal space. If [softWrap] is
* false, [overflow] and TextAlign may have unexpected effects.
* @param maxLines An optional maximum number of lines for the text to span, wrapping if
* necessary. If the text exceeds the given number of lines, it will be truncated according to
* [overflow] and [softWrap]. If it is not null, then it must be greater than zero.
* @param constraints how wide and tall the text is allowed to be. [Constraints.maxWidth]
* will define the width of the MultiParagraph. [Constraints.maxHeight] helps defining the
* number of lines that fit with ellipsis is true. [Constraints.minWidth] defines the minimum
* width the resulting [TextLayoutResult.size] will report. [Constraints.minHeight] is no-op.
* @param layoutDirection layout direction of the measurement environment. If not specified,
* defaults to the value that was given during initialization of this [TextMeasurer].
* @param density density of the measurement environment. If not specified, defaults to
* the value that was given during initialization of this [TextMeasurer].
* @param fontFamilyResolver to be used to load the font given in [SpanStyle]s. If not
* specified, defaults to the value that was given during initialization of this [TextMeasurer].
* @param skipCache Disables cache optimization if it is passed as true.
*
* @sample androidx.compose.ui.text.samples.measureTextStringWithConstraints
*/
@Stable
fun measure(
text: String,
style: TextStyle = TextStyle.Default,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
constraints: Constraints = Constraints(),
layoutDirection: LayoutDirection = this.defaultLayoutDirection,
density: Density = this.defaultDensity,
fontFamilyResolver: FontFamily.Resolver = this.defaultFontFamilyResolver,
skipCache: Boolean = false
): TextLayoutResult {
return measure(
text = AnnotatedString(text),
style = style,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
constraints = constraints,
layoutDirection = layoutDirection,
density = density,
fontFamilyResolver = fontFamilyResolver,
skipCache = skipCache
)
}
internal companion object {
/**
* Computes the visual position of the glyphs for painting the text.
*
* The text will layout with a width that's as close to its max intrinsic width as possible
* while still being greater than or equal to `minWidth` and less than or equal to
* `maxWidth`.
*/
private fun layout(
textLayoutInput: TextLayoutInput
): TextLayoutResult = with(textLayoutInput) {
val nonNullIntrinsics = MultiParagraphIntrinsics(
annotatedString = text,
style = resolveDefaults(style, layoutDirection),
density = density,
fontFamilyResolver = fontFamilyResolver,
placeholders = placeholders
)
val minWidth = constraints.minWidth
val widthMatters = softWrap || overflow == TextOverflow.Ellipsis
val maxWidth = if (widthMatters && constraints.hasBoundedWidth) {
constraints.maxWidth
} else {
Constraints.Infinity
}
// This is a fallback behavior because native text layout doesn't support multiple
// ellipsis in one text layout.
// When softWrap is turned off and overflow is ellipsis, it's expected that each line
// that exceeds maxWidth will be ellipsized.
// For example,
// input text:
// "AAAA\nAAAA"
// maxWidth:
// 3 * fontSize that only allow 3 characters to be displayed each line.
// expected output:
// AA…
// AA…
// Here we assume there won't be any '\n' character when softWrap is false. And make
// maxLines 1 to implement the similar behavior.
val overwriteMaxLines = !softWrap && overflow == TextOverflow.Ellipsis
val finalMaxLines = if (overwriteMaxLines) 1 else maxLines
// if minWidth == maxWidth the width is fixed.
// therefore we can pass that value to our paragraph and use it
// if minWidth != maxWidth there is a range
// then we should check if the max intrinsic width is in this range to decide the
// width to be passed to Paragraph
// if max intrinsic width is between minWidth and maxWidth
// we can use it to layout
// else if max intrinsic width is greater than maxWidth, we can only use maxWidth
// else if max intrinsic width is less than minWidth, we should use minWidth
val width = if (minWidth == maxWidth) {
maxWidth
} else {
nonNullIntrinsics.maxIntrinsicWidth.ceilToInt().coerceIn(minWidth, maxWidth)
}
val multiParagraph = MultiParagraph(
intrinsics = nonNullIntrinsics,
constraints = Constraints(maxWidth = width, maxHeight = constraints.maxHeight),
// This is a fallback behavior for ellipsis. Native
maxLines = finalMaxLines,
ellipsis = overflow == TextOverflow.Ellipsis
)
return TextLayoutResult(
layoutInput = textLayoutInput,
multiParagraph = multiParagraph,
size = constraints.constrain(
IntSize(
ceil(multiParagraph.width).toInt(),
ceil(multiParagraph.height).toInt()
)
)
)
}
}
}
/**
* Keeps an LRU layout cache of TextLayoutInput, TextLayoutResult pairs. Any non-layout affecting
* change in TextLayoutInput (color, brush, shadow, TextDecoration) is ignored by this cache.
*
* @param capacity Maximum size of LRU cache. Size unit is the number of [CacheTextLayoutInput]
* and [TextLayoutResult] pairs.
*
* @throws IllegalArgumentException if capacity is not a positive integer.
*/
internal class TextLayoutCache(capacity: Int = DefaultCacheSize) {
private val lruCache = LruCache<CacheTextLayoutInput, TextLayoutResult>(capacity)
fun get(key: TextLayoutInput): TextLayoutResult? {
val resultFromCache = lruCache.get(CacheTextLayoutInput(key)) ?: return null
if (resultFromCache.multiParagraph.intrinsics.hasStaleResolvedFonts) {
// one of the resolved fonts has updated, and this MeasuredText is no longer valid for
// measure or display
return null
}
return resultFromCache
}
fun put(key: TextLayoutInput, value: TextLayoutResult): TextLayoutResult? {
return lruCache.put(CacheTextLayoutInput(key), value)
}
fun remove(key: TextLayoutInput): TextLayoutResult? {
return lruCache.remove(CacheTextLayoutInput(key))
}
}
/**
* Provides custom hashCode and equals function that are only interested in layout affecting
* attributes in TextLayoutInput. Used as a key in [TextLayoutCache].
*/
@Immutable
internal class CacheTextLayoutInput(val textLayoutInput: TextLayoutInput) {
override fun hashCode(): Int = with(textLayoutInput) {
var result = text.hashCode()
result = 31 * result + style.hashCodeLayoutAffectingAttributes()
result = 31 * result + placeholders.hashCode()
result = 31 * result + maxLines
result = 31 * result + softWrap.hashCode()
result = 31 * result + overflow.hashCode()
result = 31 * result + density.hashCode()
result = 31 * result + layoutDirection.hashCode()
result = 31 * result + fontFamilyResolver.hashCode()
result = 31 * result + constraints.maxWidth.hashCode()
result = 31 * result + constraints.maxHeight.hashCode()
return result
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is CacheTextLayoutInput) return false
with(textLayoutInput) {
if (text != other.textLayoutInput.text) return false
if (!style.hasSameLayoutAffectingAttributes(other.textLayoutInput.style)) return false
if (placeholders != other.textLayoutInput.placeholders) return false
if (maxLines != other.textLayoutInput.maxLines) return false
if (softWrap != other.textLayoutInput.softWrap) return false
if (overflow != other.textLayoutInput.overflow) return false
if (density != other.textLayoutInput.density) return false
if (layoutDirection != other.textLayoutInput.layoutDirection) return false
if (fontFamilyResolver !== other.textLayoutInput.fontFamilyResolver) return false
if (constraints.maxWidth != other.textLayoutInput.constraints.maxWidth) return false
if (constraints.maxHeight != other.textLayoutInput.constraints.maxHeight) return false
}
return true
}
}