Skip to content

Instantly share code, notes, and snippets.

@segunfamisa
Last active January 19, 2026 01:23
Show Gist options
  • Select an option

  • Save segunfamisa/8c718604b8618b97fb75b3d6cc10bc22 to your computer and use it in GitHub Desktop.

Select an option

Save segunfamisa/8c718604b8618b97fb75b3d6cc10bc22 to your computer and use it in GitHub Desktop.
Exploring custom text rendering in Jetpack Compose
@Composable
fun AnimatedWarpedText(
text: String,
modifier: Modifier = Modifier,
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,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
color: Color = LocalContentColor.current,
) {
BoxWithConstraints(modifier) {
val scope = this
val density = LocalDensity.current
val mergedStyle = style.merge(
color = color,
textAlign = textAlign ?: TextAlign.Unspecified,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
lineHeight = lineHeight,
)
// measure text, considering the max width constraint
val textMeasurer = rememberTextMeasurer()
val textLayout = remember(text, mergedStyle, scope.constraints) {
textMeasurer.measure(
text = text,
style = mergedStyle,
constraints = scope.constraints
)
}
val canvasSize = with(density) {
DpSize(textLayout.size.width.toDp(), textLayout.size.height.toDp())
}
val infiniteTransition = rememberInfiniteTransition()
val sinusoidalAmplitude by
infiniteTransition.animateFloat(
initialValue = with(density) { -5.dp.toPx() },
targetValue = with(density) { 5.dp.toPx() },
animationSpec =
infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
)
Canvas(modifier = modifier.size(canvasSize)) {
for (lineIndex in 0 until textLayout.lineCount) {
val startCharIndex = textLayout.getLineStart(lineIndex)
val endCharIndex = textLayout.getLineEnd(lineIndex)
for (charIndex in startCharIndex until endCharIndex) {
val rect = textLayout.getBoundingBox(charIndex)
val char = textLayout.layoutInput.text[charIndex].toString()
withTransform({
translate(
left = 0f,
top = sinusoidalAmplitude * sin(charIndex * 0.7).toFloat()
)
}) {
drawText(
textMeasurer = textMeasurer,
text = char,
topLeft = Offset(x = rect.left, y = rect.top),
style = mergedStyle,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
)
}
}
}
}
}
}
@Composable
fun FadedText(
text: String,
modifier: Modifier = Modifier,
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,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
color: Color = LocalContentColor.current,
) {
BoxWithConstraints(modifier) {
val scope = this
val density = LocalDensity.current
val mergedStyle = style.merge(
color = color,
textAlign = textAlign ?: TextAlign.Unspecified,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
lineHeight = lineHeight,
)
// measure text, considering the max width constraint
val textMeasurer = rememberTextMeasurer()
val textLayout = remember(text, mergedStyle, scope.constraints) {
textMeasurer.measure(
text = text,
style = mergedStyle,
constraints = scope.constraints
)
}
val canvasSize = with(density) {
DpSize(textLayout.size.width.toDp(), textLayout.size.height.toDp())
}
Canvas(modifier = modifier.size(canvasSize)) {
for (lineIndex in 0 until textLayout.lineCount) {
val startCharIndex = textLayout.getLineStart(lineIndex)
val endCharIndex = textLayout.getLineEnd(lineIndex)
val lineLeftCoordinate = textLayout.getLineLeft(lineIndex)
val lineTopCoordinate = textLayout.getLineTop(lineIndex)
val alpha = mergedStyle.color.alpha * lineIndex.toFloat() / textLayout.lineCount
val lineText = text.substring(startCharIndex, endCharIndex)
drawText(
textMeasurer = textMeasurer,
text = lineText,
topLeft = Offset(x = lineLeftCoordinate, y = lineTopCoordinate),
style = mergedStyle.copy(color = mergedStyle.color.copy(alpha = alpha)),
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
)
}
}
}
}
@Composable
fun TypewriterText(
text: String,
modifier: Modifier = Modifier,
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,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
color: Color = LocalContentColor.current,
) {
BoxWithConstraints(modifier) {
val scope = this
val density = LocalDensity.current
val mergedStyle = style.merge(
color = color,
textAlign = textAlign ?: TextAlign.Unspecified,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
lineHeight = lineHeight,
)
// measure text, considering the max width constraint
val textMeasurer = rememberTextMeasurer()
val textLayout = remember(text, mergedStyle, scope.constraints) {
textMeasurer.measure(
text = text,
style = mergedStyle,
constraints = scope.constraints
)
}
// initialize the animation
val animatedCharacterCount = remember { Animatable(0f) }
LaunchedEffect(text) {
animatedCharacterCount.animateTo(
targetValue = text.length.toFloat(),
animationSpec = tween(durationMillis = text.length * 50, easing = LinearEasing)
)
}
val canvasSize = with(density) {
DpSize(textLayout.size.width.toDp(), textLayout.size.height.toDp())
}
Canvas(modifier = modifier.size(canvasSize)) {
val lines = textLayout.lineCount
val visibleChars = animatedCharacterCount.value.toInt()
for (lineIndex in 0 until lines) {
val startCharIndex = textLayout.getLineStart(lineIndex)
val endCharIndex = textLayout.getLineEnd(lineIndex)
if (visibleChars > startCharIndex) {
val topCoordinate = textLayout.getLineTop(lineIndex)
val leftCoordinate = textLayout.getLineLeft(lineIndex)
// we only want to show as far as the end character index
val displayedEndIndex = minOf(endCharIndex, visibleChars)
val displayedText =
textLayout.layoutInput.text.substring(startCharIndex, displayedEndIndex)
drawText(
textMeasurer = textMeasurer,
text = displayedText,
topLeft = Offset(x = leftCoordinate, y = topCoordinate),
style = mergedStyle,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
)
}
}
}
}
}
@Composable
fun WarpedText(
text: String,
modifier: Modifier = Modifier,
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,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
color: Color = LocalContentColor.current,
) {
BoxWithConstraints(modifier) {
val scope = this
val density = LocalDensity.current
val maxWidthPx = with(density) { scope.maxWidth.toPx() }
val mergedStyle = style.merge(
color = color,
textAlign = textAlign ?: TextAlign.Unspecified,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
lineHeight = lineHeight,
)
// measure text, considering the max width constraint
val textMeasurer = rememberTextMeasurer()
val textLayout = remember(text, mergedStyle, constraints) {
textMeasurer.measure(
text = text,
style = mergedStyle,
constraints = constraints
)
}
val canvasSize = with(density) {
DpSize(textLayout.size.width.toDp(), textLayout.size.height.toDp())
}
Canvas(modifier = modifier.size(canvasSize)) {
for (lineIndex in 0 until textLayout.lineCount) {
val startCharIndex = textLayout.getLineStart(lineIndex)
val endCharIndex = textLayout.getLineEnd(lineIndex)
for (charIndex in startCharIndex until endCharIndex) {
val rect = textLayout.getBoundingBox(charIndex)
val char = textLayout.layoutInput.text[charIndex].toString()
withTransform({
translate(
left = 0f,
top = 5 * sin(charIndex * 0.7).toFloat()
)
}) {
drawText(
textMeasurer = textMeasurer,
text = char,
topLeft = Offset(x = rect.left, y = rect.top),
style = mergedStyle,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
)
}
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment