This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Preview | |
@Composable | |
fun EllipsisTextPreview( | |
) { | |
EllipsisText( | |
text = "This is a very long text that should This is a very long text that should This is a very long text that should bbbThis is a very long text that should be truncated", | |
maxLines = 2, | |
) | |
} | |
@Composable | |
fun EllipsisText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
fontSize: TextUnit = TextUnit.Unspecified, | |
fontStyle: FontStyle? = null, | |
fontWeight: FontWeight? = null, | |
fontFamily: FontFamily? = null, | |
letterSpacing: TextUnit = TextUnit.Unspecified, | |
textDecoration: TextDecoration? = null, | |
textAlign: TextAlign? = null, | |
lineHeight: TextUnit = TextUnit.Unspecified, | |
softWrap: Boolean = true, | |
maxLines: Int = Int.MAX_VALUE, | |
onEllipsisText: Context.() -> String = { "..." }, | |
onTextLayout: (TextLayoutResult) -> Unit = {}, | |
style: TextStyle = LocalTextStyle.current, | |
) { | |
val context = LocalContext.current | |
val ellipsisText = context.onEllipsisText() | |
val ellipsisCharactersCount = ellipsisText.length | |
// some letters, like "r", will have less width when placed right before "." | |
// adding a space to prevent such case | |
val layoutText = remember(text) { "$text $ellipsisText" } | |
val textLayoutResultState = remember(layoutText) { | |
mutableStateOf<TextLayoutResult?>(null) | |
} | |
SubcomposeLayout(modifier) { constraints -> | |
// result is ignored - we only need to fill our textLayoutResult | |
subcompose("measure") { | |
Text( | |
text = layoutText, | |
color = color, | |
fontSize = fontSize, | |
fontStyle = fontStyle, | |
fontWeight = fontWeight, | |
fontFamily = fontFamily, | |
letterSpacing = letterSpacing, | |
textDecoration = textDecoration, | |
textAlign = textAlign, | |
lineHeight = lineHeight, | |
softWrap = softWrap, | |
maxLines = maxLines, | |
onTextLayout = { textLayoutResultState.value = it }, | |
style = style, | |
) | |
}.first().measure(Constraints()) | |
// to allow smart cast | |
val textLayoutResult = textLayoutResultState.value | |
?: // shouldn't happen - onTextLayout is called before subcompose finishes | |
return@SubcomposeLayout layout(0, 0) {} | |
val placeable = subcompose("visible") { | |
val finalText = remember(text, textLayoutResult, constraints.maxWidth) { | |
if (text.isEmpty() || textLayoutResult.getBoundingBox(text.indices.last).right <= constraints.maxWidth) { | |
// text not including ellipsis fits on the first line. | |
return@remember text | |
} | |
val ellipsisWidth = layoutText.indices.toList() | |
.takeLast(ellipsisCharactersCount) | |
.let widthLet@{ indices -> | |
// fix this bug: https://issuetracker.google.com/issues/197146630 | |
// in this case width is invalid | |
for (i in indices) { | |
val width = textLayoutResult.getBoundingBox(i).width | |
if (width > 0) { | |
return@widthLet width * ellipsisCharactersCount | |
} | |
} | |
// this should not happen, because | |
// this error occurs only for the last character in the string | |
throw IllegalStateException("all ellipsis chars have invalid width") | |
} | |
val availableWidth = constraints.maxWidth * maxLines - ellipsisWidth | |
val startCounter = BoundCounter(text, textLayoutResult) { it } | |
while (availableWidth - startCounter.width > 0) { | |
if (availableWidth - startCounter.widthWithNextChar() >= 0) { | |
startCounter.addNextChar() | |
} else { | |
break | |
} | |
} | |
"${startCounter.string.trimEnd()}${ellipsisText}" | |
} | |
Text( | |
text = finalText, | |
color = color, | |
fontSize = fontSize, | |
fontStyle = fontStyle, | |
fontWeight = fontWeight, | |
fontFamily = fontFamily, | |
letterSpacing = letterSpacing, | |
textDecoration = textDecoration, | |
textAlign = textAlign, | |
lineHeight = lineHeight, | |
softWrap = softWrap, | |
onTextLayout = onTextLayout, | |
style = style, | |
) | |
}[0].measure(constraints) | |
layout(placeable.width, placeable.height) { | |
placeable.place(0, 0) | |
} | |
} | |
} | |
private class BoundCounter( | |
private val text: String, | |
private val textLayoutResult: TextLayoutResult, | |
private val charPosition: (Int) -> Int, | |
) { | |
var string = "" | |
private set | |
var width = 0f | |
private set | |
private var _nextCharWidth: Float? = null | |
private var invalidCharsCount = 0 | |
fun widthWithNextChar(): Float = | |
width + nextCharWidth() | |
private fun nextCharWidth(): Float = | |
_nextCharWidth ?: run { | |
var boundingBox: Rect | |
// invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630 | |
invalidCharsCount-- | |
do { | |
boundingBox = textLayoutResult | |
.getBoundingBox(charPosition(string.count() + ++invalidCharsCount)) | |
} while (boundingBox.right == 0f) | |
_nextCharWidth = boundingBox.width | |
boundingBox.width | |
} | |
fun addNextChar() { | |
string += text[charPosition(string.count())] | |
width += nextCharWidth() | |
_nextCharWidth = null | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Composable | |
fun EllipsisText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
fontSize: TextUnit = TextUnit.Unspecified, | |
fontStyle: FontStyle? = null, | |
fontWeight: FontWeight? = null, | |
fontFamily: FontFamily? = null, | |
letterSpacing: TextUnit = TextUnit.Unspecified, | |
textDecoration: TextDecoration? = null, | |
textAlign: TextAlign? = null, | |
lineHeight: TextUnit = TextUnit.Unspecified, | |
softWrap: Boolean = true, | |
maxLines: Int = Int.MAX_VALUE, | |
onEllipsisText: Context.() -> String = { "..." }, | |
onTextLayout: (TextLayoutResult) -> Unit = {}, | |
style: TextStyle = LocalTextStyle.current, | |
) { | |
val context = LocalContext.current | |
val ellipsisText = context.onEllipsisText() | |
// Measure the width of the ellipsis text | |
val ellipsisWidth = with(LocalDensity.current) { | |
remember(ellipsisText) { | |
val textPainter = TextPainter( | |
text = AnnotatedString(ellipsisText), | |
style = style, | |
modifier = Modifier | |
) | |
textPainter.layout(IntPx.Infinity) | |
textPainter.width.toDp() | |
} | |
}.value | |
// some letters, like "r", will have less width when placed right before "." | |
// adding a space to prevent such case | |
val layoutText = remember(text) { "$text $ellipsisText" } | |
val textLayoutResultState = remember(layoutText) { | |
mutableStateOf<TextLayoutResult?>(null) | |
} | |
SubcomposeLayout(modifier) { constraints -> | |
// result is ignored - we only need to fill our textLayoutResult | |
subcompose("measure") { | |
Text( | |
text = layoutText, | |
color = color, | |
fontSize = fontSize, | |
fontStyle = fontStyle, | |
fontWeight = fontWeight, | |
fontFamily = fontFamily, | |
letterSpacing = letterSpacing, | |
textDecoration = textDecoration, | |
textAlign = textAlign, | |
lineHeight = lineHeight, | |
softWrap = softWrap, | |
maxLines = maxLines, | |
onTextLayout = { textLayoutResultState.value = it }, | |
style = style, | |
) | |
}.first().measure(Constraints()) | |
// to allow smart cast | |
val textLayoutResult = textLayoutResultState.value | |
?: // shouldn't happen - onTextLayout is called before subcompose finishes | |
return@SubcomposeLayout layout(0, 0) {} | |
val placeable = subcompose("visible") { | |
val finalText = if (textLayoutResult.hasVisualOverflow) { | |
val availableWidth = constraints.maxWidth | |
val builder = AnnotatedString.Builder() | |
var lineStartOffset = 0 | |
var lineEndOffset: Int | |
for (lineIndex in 0 until maxLines) { | |
lineEndOffset = textLayoutResult.getLineEnd(lineIndex) | |
if (lineIndex == maxLines - 1) { | |
// Last line | |
val lineText = text.substring(lineStartOffset, lineEndOffset) | |
if (textLayoutResult.painter.measureText(lineText).toDp().value > availableWidth) { | |
// The line exceeds the available width, so we need to truncate it | |
var truncatedLineText = lineText | |
var lineTextWidth = textLayoutResult.painter.measureText(truncatedLineText).toDp().value | |
while (lineTextWidth + ellipsisWidth > availableWidth && truncatedLineText.isNotEmpty()) { | |
val lastIndex = truncatedLineText.length - 1 | |
val lastCharWidth = textLayoutResult.painter.measureText(truncatedLineText[lastIndex].toString()).toDp().value | |
truncatedLineText = truncatedLineText.dropLast(1) | |
lineTextWidth -= lastCharWidth | |
} | |
builder.pushStyle(style) | |
builder.append(truncatedLineText) | |
builder.pop() | |
builder.append(ellipsisText) | |
} else { | |
// The line fits within the available width, no need to truncate | |
builder.pushStyle(style) | |
builder.append(lineText) | |
builder.pop() | |
} | |
} else { | |
// Not the last line, append it as it is | |
val lineText = text.substring(lineStartOffset, lineEndOffset) | |
builder.pushStyle(style) | |
builder.append(lineText) | |
builder.pop() | |
// Append newline character | |
builder.append("\n") | |
lineStartOffset = lineEndOffset + 1 | |
} | |
} | |
builder.toAnnotatedString() | |
} else text | |
Text( | |
text = finalText, | |
color = color, | |
fontSize = fontSize, | |
fontStyle = fontStyle, | |
fontWeight = fontWeight, | |
fontFamily = fontFamily, | |
letterSpacing = letterSpacing, | |
textDecoration = textDecoration, | |
textAlign = textAlign, | |
lineHeight = lineHeight, | |
softWrap = softWrap, | |
onTextLayout = onTextLayout, | |
style = style, | |
) | |
}[0].measure(constraints) | |
layout(placeable.width, placeable.height) { | |
placeable.place(0, 0) | |
} | |
} | |
} |
Author
yongjhih
commented
Jul 26, 2023
fun CharSequence.trySubstring(range: IntRange): String = trySubstring(range.first, range.last + 1)
fun CharSequence.trySubstring(start: Int, end: Int = length): String {
val left = start.coerceAtLeast(0).coerceAtMost(length)
val right = end.coerceAtLeast(0).coerceAtMost(length)
return substring(
left.coerceAtMost(right),
right.coerceAtLeast(left),
)
}
/**
- Half-width: Regular width characters.
- Eg. 'A' and 'ニ'
- Full-width: Chars that take two monospaced English chars' space on the display
- Eg. '中', 'に' and 'A'
- See FullWidthUtilGenerator.kt
- @return Is this character a full-width character or not.
*/
val Char.isFullWidth: Boolean get() = when (this) {
'\u2329','\u232A','\u23F0','\u23F3','\u267F','\u2693','\u26A1','\u26CE','\u26D4','\u26EA','\u26F5',
'\u26FA','\u26FD','\u2705','\u2728','\u274C','\u274E','\u2757','\u27B0','\u27BF','\u2B50','\u2B55',
'\u3000','\u3004','\u3005','\u3006','\u3007','\u3008','\u3009','\u300A','\u300B','\u300C','\u300D',
'\u300E','\u300F','\u3010','\u3011','\u3014','\u3015','\u3016','\u3017','\u3018','\u3019','\u301A',
'\u301B','\u301C','\u301D','\u3020','\u3030','\u303B','\u303C','\u303D','\u303E','\u309F','\u30A0',
'\u30FB','\u30FF','\u3250','\uA015','\uFE17','\uFE18','\uFE19','\uFE30','\uFE35','\uFE36','\uFE37',
'\uFE38','\uFE39','\uFE3A','\uFE3B','\uFE3C','\uFE3D','\uFE3E','\uFE3F','\uFE40','\uFE41','\uFE42',
'\uFE43','\uFE44','\uFE47','\uFE48','\uFE58','\uFE59','\uFE5A','\uFE5B','\uFE5C','\uFE5D','\uFE5E',
'\uFE62','\uFE63','\uFE68','\uFE69','\uFF04','\uFF08','\uFF09','\uFF0A','\uFF0B','\uFF0C','\uFF0D',
'\uFF3B','\uFF3C','\uFF3D','\uFF3E','\uFF3F','\uFF40','\uFF5B','\uFF5C','\uFF5D','\uFF5E','\uFF5F',
'\uFF60','\uFFE2','\uFFE3','\uFFE4',
in '\u1100'..'\u115F',in '\u231A'..'\u231B',in '\u23E9'..'\u23EC',in '\u25FD'..'\u25FE',
in '\u2614'..'\u2615',in '\u2648'..'\u2653',in '\u26AA'..'\u26AB',in '\u26BD'..'\u26BE',
in '\u26C4'..'\u26C5',in '\u26F2'..'\u26F3',in '\u270A'..'\u270B',in '\u2753'..'\u2755',
in '\u2795'..'\u2797',in '\u2B1B'..'\u2B1C',in '\u2E80'..'\u2E99',in '\u2E9B'..'\u2EF3',
in '\u2F00'..'\u2FD5',in '\u2FF0'..'\u2FFB',in '\u3001'..'\u3003',in '\u3012'..'\u3013',
in '\u301E'..'\u301F',in '\u3021'..'\u3029',in '\u302A'..'\u302D',in '\u302E'..'\u302F',
in '\u3031'..'\u3035',in '\u3036'..'\u3037',in '\u3038'..'\u303A',in '\u3041'..'\u3096',
in '\u3099'..'\u309A',in '\u309B'..'\u309C',in '\u309D'..'\u309E',in '\u30A1'..'\u30FA',
in '\u30FC'..'\u30FE',in '\u3105'..'\u312F',in '\u3131'..'\u318E',in '\u3190'..'\u3191',
in '\u3192'..'\u3195',in '\u3196'..'\u319F',in '\u31A0'..'\u31BF',in '\u31C0'..'\u31E3',
in '\u31F0'..'\u31FF',in '\u3200'..'\u321E',in '\u3220'..'\u3229',in '\u322A'..'\u3247',
in '\u3251'..'\u325F',in '\u3260'..'\u327F',in '\u3280'..'\u3289',in '\u328A'..'\u32B0',
in '\u32B1'..'\u32BF',in '\u32C0'..'\u32FF',in '\u3300'..'\u33FF',in '\u3400'..'\u4DBF',
in '\u4E00'..'\u9FFC',in '\u9FFD'..'\u9FFF',in '\uA000'..'\uA014',in '\uA016'..'\uA48C',
in '\uA490'..'\uA4C6',in '\uA960'..'\uA97C',in '\uAC00'..'\uD7A3',in '\uF900'..'\uFA6D',
in '\uFA6E'..'\uFA6F',in '\uFA70'..'\uFAD9',in '\uFADA'..'\uFAFF',in '\uFE10'..'\uFE16',
in '\uFE31'..'\uFE32',in '\uFE33'..'\uFE34',in '\uFE45'..'\uFE46',in '\uFE49'..'\uFE4C',
in '\uFE4D'..'\uFE4F',in '\uFE50'..'\uFE52',in '\uFE54'..'\uFE57',in '\uFE5F'..'\uFE61',
in '\uFE64'..'\uFE66',in '\uFE6A'..'\uFE6B',in '\uFF01'..'\uFF03',in '\uFF05'..'\uFF07',
in '\uFF0E'..'\uFF0F',in '\uFF10'..'\uFF19',in '\uFF1A'..'\uFF1B',in '\uFF1C'..'\uFF1E',
in '\uFF1F'..'\uFF20',in '\uFF21'..'\uFF3A',in '\uFF41'..'\uFF5A',in '\uFFE0'..'\uFFE1',
in '\uFFE5'..'\uFFE6' -> true
else -> false
}
val CharSequence.hasFullWidth get() = any { it.isFullWidth }
/**
- Trims the input [CharSequence] from the end based on the defined width.
- This function trims the characters that can be accommodated within the given [width] from the end of the input.
- While trimming, each character is treated as having either a full width (value of 2) or a single width (value of 1).
- The function takes [width] as an input parameter, which must be greater than 0 to apply the trimming.
- If [width] is less than or equal to 0, it returns the original input [CharSequence] without trimming.
- @param width the width used to determine how many characters will be removed from the end of the input [CharSequence]
- @return a new [CharSequence] after trimming from the end based on the defined width
*/
fun CharSequence.trimEndWidth(width: Int): CharSequence {
if (width <= 0) return this
var count = 0
for (i in indices.reversed()) {
if (count >= width) {
return trySubstring(0, i + 1)
}
count += this[i].width
}
return ""
}
val CharSequence.width: Int get() = sumOf { it.width }
val Char.width: Int get() = if (isFullWidth) 2 else 1
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment