Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-04 19:19:44 +05:00
parent 469c0a4317
commit c2d9984101

View file

@ -0,0 +1,485 @@
package com.tangem.core.ui.components.atoms.text
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
/**
*
* 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
*
* https://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.
**/
private const val READ_MORE_TAG = "read_more"
private const val READ_LESS_TAG = "read_less"
/**
* Basic element that displays text with read more.
*
* @param text The text to be displayed.
* @param expanded whether this text is expanded or collapsed.
* @param modifier [Modifier] to apply to this layout node.
* @param onExpandRequested called when this text is clicked. If `null`, then this text will not be
* interactable, unless something else handles its input events and updates its state.
* @param contentPadding a padding around the text.
* @param style Style configuration for the text such as color, font, line height etc.
* @param onTextLayout Callback that is executed when a new text layout is calculated. A
* [TextLayoutResult] object that callback provides contains paragraph information, size of the
* text, baselines and other details. The callback can be used to add additional decoration or
* functionality to the text. For example, to draw selection around the text.
* @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,
* [readMoreOverflow] and TextAlign may have unexpected effects.
* @param readMoreText The read more text to be displayed in the collapsed state.
* @param readMoreMaxLines 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
* [readMoreOverflow]. If it is not null, then it must be greater than zero.
* @param readMoreOverflow How visual overflow should be handled in the collapsed state.
* @param readMoreStyle Style configuration for the read more text such as color, font, line height
* etc.
* @param readLessText The read less text to be displayed in the expanded state.
* @param readLessStyle Style configuration for the read less text such as color, font, line height
* etc.
* @param toggleArea A clickable area of text to toggle.
*/
@Composable
fun ReadMoreText(
text: String,
expanded: Boolean,
modifier: Modifier = Modifier,
onExpandRequested: ((Boolean) -> Unit)? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
style: TextStyle = TextStyle.Default,
onTextLayout: (TextLayoutResult) -> Unit = {},
softWrap: Boolean = true,
readMoreText: String = "",
readMoreMaxLines: Int = 2,
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
readMoreStyle: SpanStyle = style.toSpanStyle(),
readLessText: String = "",
readLessStyle: SpanStyle = readMoreStyle,
toggleArea: ToggleArea = ToggleArea.All,
) {
ReadMoreTextInternal(
text = AnnotatedString(text),
expanded = expanded,
modifier = modifier,
onExpandRequested = onExpandRequested,
contentPadding = contentPadding,
style = style,
onTextLayout = onTextLayout,
softWrap = softWrap,
readMoreText = readMoreText,
readMoreMaxLines = readMoreMaxLines,
readMoreOverflow = readMoreOverflow,
readMoreStyle = readMoreStyle,
readLessText = readLessText,
readLessStyle = readLessStyle,
toggleArea = toggleArea,
)
}
/**
* Basic element that displays text with read more.
*
* @param text The text to be displayed.
* @param expanded whether this text is expanded or collapsed.
* @param modifier [Modifier] to apply to this layout node.
* @param onExpandRequested called when this text is clicked. If `null`, then this text will not be
* interactable, unless something else handles its input events and updates its state.
* @param contentPadding a padding around the text.
* @param style Style configuration for the text such as color, font, line height etc.
* @param onTextLayout Callback that is executed when a new text layout is calculated. A
* [TextLayoutResult] object that callback provides contains paragraph information, size of the
* text, baselines and other details. The callback can be used to add additional decoration or
* functionality to the text. For example, to draw selection around the text.
* @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,
* [readMoreOverflow] and TextAlign may have unexpected effects.
* @param readMoreText The read more text to be displayed in the collapsed state.
* @param readMoreMaxLines 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
* [readMoreOverflow]. If it is not null, then it must be greater than zero.
* @param readMoreOverflow How visual overflow should be handled in the collapsed state.
* @param readMoreStyle Style configuration for the read more text such as color, font, line height
* etc.
* @param readLessText The read less text to be displayed in the expanded state.
* @param readLessStyle Style configuration for the read less text such as color, font, line height
* etc.
* @param toggleArea A clickable area of text to toggle.
*/
@Composable
fun ReadMoreText(
text: AnnotatedString,
expanded: Boolean,
modifier: Modifier = Modifier,
onExpandRequested: ((Boolean) -> Unit)? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
style: TextStyle = TextStyle.Default,
onTextLayout: (TextLayoutResult) -> Unit = {},
softWrap: Boolean = true,
readMoreText: String = "",
readMoreMaxLines: Int = 2,
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
readMoreStyle: SpanStyle = style.toSpanStyle(),
readLessText: String = "",
readLessStyle: SpanStyle = readMoreStyle,
toggleArea: ToggleArea = ToggleArea.All,
) {
ReadMoreTextInternal(
text = text,
expanded = expanded,
modifier = modifier,
onExpandRequested = onExpandRequested,
contentPadding = contentPadding,
style = style,
onTextLayout = onTextLayout,
softWrap = softWrap,
readMoreText = readMoreText,
readMoreMaxLines = readMoreMaxLines,
readMoreOverflow = readMoreOverflow,
readMoreStyle = readMoreStyle,
readLessText = readLessText,
readLessStyle = readLessStyle,
toggleArea = toggleArea,
)
}
@Suppress("LongMethod", "LongParameterList")
@Composable
private fun ReadMoreTextInternal(
text: AnnotatedString,
expanded: Boolean,
modifier: Modifier = Modifier,
onExpandRequested: ((Boolean) -> Unit)? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
style: TextStyle = TextStyle.Default,
onTextLayout: (TextLayoutResult) -> Unit = {},
softWrap: Boolean = true,
readMoreText: String = "",
readMoreMaxLines: Int = 2,
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
readMoreStyle: SpanStyle = style.toSpanStyle(),
readLessText: String = "",
readLessStyle: SpanStyle = readMoreStyle,
toggleArea: ToggleArea = ToggleArea.All,
) {
require(readMoreMaxLines > 0) { "readMoreMaxLines should be greater than 0" }
val overflowText: String = remember(readMoreOverflow) {
buildString {
when (readMoreOverflow) {
ReadMoreTextOverflow.Clip -> {
}
ReadMoreTextOverflow.Ellipsis -> {
append(Typography.ellipsis)
}
}
if (readMoreText.isNotEmpty()) {
append(Typography.nbsp)
}
}
}
val readMoreTextWithStyle: AnnotatedString = remember(readMoreText, readMoreStyle) {
buildAnnotatedString {
if (readMoreText.isNotEmpty()) {
withStyle(readMoreStyle) {
append(readMoreText.replace(' ', Typography.nbsp))
}
}
}
}
val readLessTextWithStyle: AnnotatedString = remember(readLessText, readLessStyle) {
buildAnnotatedString {
if (readLessText.isNotEmpty()) {
withStyle(readLessStyle) {
append(readLessText)
}
}
}
}
val textMeasurer = rememberTextMeasurer()
val state = remember { ReadMoreState() }
val currentText = buildAnnotatedString {
if (expanded) {
append(text)
if (readLessTextWithStyle.isNotEmpty()) {
append(' ')
if (toggleArea == ToggleArea.More) {
withLink(
LinkAnnotation.Clickable(tag = READ_LESS_TAG) {
onExpandRequested?.invoke(false)
},
) {
append(readLessTextWithStyle)
}
} else {
append(readLessTextWithStyle)
}
}
} else {
val collapsedText = state.collapsedText
if (collapsedText.isNotEmpty()) {
append(collapsedText)
append(overflowText)
if (toggleArea == ToggleArea.More) {
withLink(
LinkAnnotation.Clickable(tag = READ_MORE_TAG) {
onExpandRequested?.invoke(true)
},
) {
append(readMoreTextWithStyle)
}
} else {
append(readMoreTextWithStyle)
}
} else {
append(text)
}
}
}
val toggleableModifier = if (onExpandRequested != null && toggleArea == ToggleArea.All) {
Modifier.clickable(
enabled = state.isCollapsible,
onClick = { onExpandRequested(!expanded) },
)
} else {
Modifier
}
BoxWithConstraints(
modifier = modifier
.then(toggleableModifier)
.padding(contentPadding),
) {
BasicText(
text = currentText,
modifier = Modifier,
style = style,
onTextLayout = onTextLayout,
overflow = TextOverflow.Ellipsis,
softWrap = softWrap,
maxLines = if (expanded) Int.MAX_VALUE else readMoreMaxLines,
)
val constraints = Constraints(maxWidth = constraints.maxWidth)
LaunchedEffect(
textMeasurer,
constraints,
overflowText,
readMoreTextWithStyle,
style,
readMoreStyle,
text,
readMoreMaxLines,
softWrap,
) {
state.applyCollapsedText(
textMeasurer = textMeasurer,
constraints = constraints,
overflowText = overflowText,
readMoreTextWithStyle = readMoreTextWithStyle,
style = style,
readMoreStyle = readMoreStyle,
text = text,
readMoreMaxLines = readMoreMaxLines,
softWrap = softWrap,
)
}
}
}
@Stable
private class ReadMoreState {
private var _collapsedText: AnnotatedString by mutableStateOf(AnnotatedString(""))
var collapsedText: AnnotatedString
get() = _collapsedText
internal set(value) {
if (value != _collapsedText) {
_collapsedText = value
}
}
val isCollapsible: Boolean
get() = collapsedText.isNotEmpty()
@Suppress("LongParameterList")
fun applyCollapsedText(
textMeasurer: TextMeasurer,
constraints: Constraints,
overflowText: String,
readMoreTextWithStyle: AnnotatedString,
style: TextStyle,
readMoreStyle: SpanStyle,
text: AnnotatedString,
readMoreMaxLines: Int,
softWrap: Boolean,
) {
val overflowTextWidth = if (overflowText.isNotEmpty()) {
textMeasurer.measure(
text = overflowText,
style = style,
).size.width
} else {
0
}
val readMoreTextWidth = if (readMoreTextWithStyle.isNotEmpty()) {
textMeasurer.measure(
text = readMoreTextWithStyle,
style = style.merge(readMoreStyle),
).size.width
} else {
0
}
val textLayout = textMeasurer.measure(
text = text,
style = style,
maxLines = readMoreMaxLines,
overflow = TextOverflow.Clip,
softWrap = softWrap,
constraints = constraints,
)
val clipTextCount = textLayout.getLineEnd(lineIndex = textLayout.lineCount - 1)
val isLineClipped = text.count() > clipTextCount
if (isLineClipped) {
val countUntilMaxLine =
textLayout.getLineEnd(readMoreMaxLines - 1, visibleEnd = true)
val decorationWidth = overflowTextWidth + readMoreTextWidth
val replaceCount = text
.substringOf(textLayout, line = readMoreMaxLines)
.calculateReplaceCountToBeSingleLineWith(
maximumTextWidth = constraints.maxWidth - decorationWidth,
measureTextWidth = { subText ->
textMeasurer.measure(
text = subText,
style = style,
softWrap = softWrap,
).size.width
},
)
collapsedText = text.subSequence(0, countUntilMaxLine - replaceCount)
} else {
collapsedText = AnnotatedString("")
}
}
private fun AnnotatedString.substringOf(layout: TextLayoutResult, line: Int): AnnotatedString {
val lastLineStartIndex = layout.getLineStart(line - 1)
val lastLineEndIndex = layout.getLineEnd(line - 1, visibleEnd = true)
return subSequence(lastLineStartIndex, lastLineEndIndex)
}
private inline fun AnnotatedString.calculateReplaceCountToBeSingleLineWith(
maximumTextWidth: Int,
measureTextWidth: (subText: AnnotatedString) -> Int,
): Int {
var replacedTextWidth: Int
var replacedCount = -1
do {
replacedCount++
replacedTextWidth = measureTextWidth(
subSequence(0, this.length - replacedCount),
)
} while (replacedCount < this.length && replacedTextWidth >= maximumTextWidth)
val lastVisibleChar: Char? = this.getOrNull(this.length - replacedCount - 1)
val firstOverflowChar: Char? = this.getOrNull(this.length - replacedCount)
if (lastVisibleChar?.isSurrogate() == true && firstOverflowChar?.isHighSurrogate() == false) {
val subText = subSequence(0, this.length - replacedCount)
if (subText.isNotEmpty()) {
return length - subText.indexOfLast { it.isHighSurrogate() }
}
}
return replacedCount
}
}
@JvmInline
value class ToggleArea private constructor(internal val value: Int) {
override fun toString(): String {
return when (this) {
All -> "All"
More -> "More"
else -> "Invalid"
}
}
companion object {
/**
* All area of the text is clickable to toggle.
*/
@Stable
val All: ToggleArea = ToggleArea(1)
/**
* 'More' and 'Less' area of the text is clickable to toggle.
*/
@Stable
val More: ToggleArea = ToggleArea(2)
}
}
@JvmInline
value class ReadMoreTextOverflow private constructor(internal val value: Int) {
override fun toString(): String {
return when (this) {
Clip -> "Clip"
Ellipsis -> "Ellipsis"
else -> "Invalid"
}
}
companion object {
/**
* Clip the overflowing text to fix its container.
*/
@Stable
val Clip: ReadMoreTextOverflow = ReadMoreTextOverflow(1)
/**
* Use an ellipsis to indicate that the text has overflowed.
*/
@Stable
val Ellipsis: ReadMoreTextOverflow = ReadMoreTextOverflow(2)
}
}