Skip to content

Instantly share code, notes, and snippets.

@yongjhih
Last active July 26, 2023 07:31
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Embed
What would you like to do?
@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
}
}
@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)
}
}
}
@yongjhih
Copy link
Author

        val ellipsisWidths = layoutText.indices.toList()
            .takeLast(ellipsisTextLength)
            .map { textLayoutResultState?.getBoundingBox(it)?.width }
        val anyEllipsisWidth = ellipsisWidths.firstOrNull { (it ?: -1f) > 0f } ?: 0f
        val ellipsisWidth = ellipsisWidths.mapNotNull { it ?: anyEllipsisWidth }.sum()

        text.trySubstring(0, lastCharIndex)
            .dropLast(moreText.length)

@yongjhih
Copy link
Author

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