Skip to content

Instantly share code, notes, and snippets.

@L10n42
Created July 12, 2024 13:52
Show Gist options
  • Save L10n42/19c9c071e74edcd5176a98f78e9f8ac4 to your computer and use it in GitHub Desktop.
Save L10n42/19c9c071e74edcd5176a98f78e9f8ac4 to your computer and use it in GitHub Desktop.
Text Shape in Jetpack Compose
import androidx.annotation.FloatRange
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.abs
/**
* A custom [Shape] that creates a dynamic background shape for text.
*
* @property textLayoutResult The layout result of the text to shape.
* @property padding The horizontal padding to apply to the text lines.
* @property corners The radius for the curves used to draw the shape.
*/
class TextShape(
private val textLayoutResult: TextLayoutResult,
private val padding: TextShapePadding = TextShapePadding.Flexible,
private val corners: TextShapeCorners = TextShapeCorners.Flexible()
) : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val lineCount = textLayoutResult.lineCount
val textStyle = textLayoutResult.layoutInput.style
val lineHeight = with(density) { textStyle.lineHeight.toPx() }
val lineRects = mutableMapOf<Int, Rect>()
val paddingPx = padding.calculatePadding(density, textStyle)
val curveRadiusPx = corners.calculateRadius(density, textStyle).coerceIn(0f, lineHeight / 2)
val shapePath = Path().apply {
var previousLine: Rect = lineRects.getOrPut(0) {
textLayoutResult.getLineRect(0).addHorizontalPadding(paddingPx)
}
moveTo(previousLine.left, previousLine.top + curveRadiusPx)
quadraticBezierTo(
x1 = previousLine.left, y1 = previousLine.top,
x2 = previousLine.left + curveRadiusPx, y2 = previousLine.top
)
lineTo(previousLine.right - curveRadiusPx, previousLine.top)
quadraticBezierTo(
x1 = previousLine.right, y1 = previousLine.top,
x2 = previousLine.right, y2 = previousLine.top + curveRadiusPx
)
lineTo(previousLine.right, previousLine.bottom - curveRadiusPx)
for (i in 1 until lineCount) {
val currentLine = lineRects.getOrPut(i) {
textLayoutResult.getLineRect(i).addHorizontalPadding(paddingPx)
}
when {
abs(currentLine.right - previousLine.right) > curveRadiusPx -> {
val normalizedCurveRadius = if (currentLine.right > previousLine.right) curveRadiusPx else -curveRadiusPx
quadraticBezierTo(
x1 = previousLine.right, y1 = previousLine.bottom,
x2 = previousLine.right + normalizedCurveRadius, y2 = currentLine.top
)
lineTo(currentLine.right - normalizedCurveRadius, currentLine.top)
quadraticBezierTo(
x1 = currentLine.right, y1 = currentLine.top,
x2 = currentLine.right, y2 = currentLine.top + curveRadiusPx
)
}
else -> {
cubicTo(
x1 = previousLine.right, y1 = previousLine.bottom,
x2 = currentLine.right, y2 = currentLine.top,
x3 = currentLine.right, y3 = currentLine.top + curveRadiusPx
)
}
}
lineTo(currentLine.right, currentLine.bottom - curveRadiusPx)
previousLine = currentLine
}
quadraticBezierTo(
x1 = previousLine.right, y1 = previousLine.bottom,
x2 = previousLine.right - curveRadiusPx, y2 = previousLine.bottom
)
lineTo(previousLine.left + curveRadiusPx, previousLine.bottom)
quadraticBezierTo(
x1 = previousLine.left, y1 = previousLine.bottom,
x2 = previousLine.left, y2 = previousLine.bottom - curveRadiusPx
)
lineTo(previousLine.left, previousLine.top + curveRadiusPx)
for (i in lineCount - 2 downTo 0) {
val currentLine = lineRects.getOrPut(i) {
textLayoutResult.getLineRect(i).addHorizontalPadding(paddingPx)
}
when {
abs(previousLine.left - currentLine.left) > curveRadiusPx -> {
val normalizedCurveRadius = if (previousLine.left > currentLine.left) -curveRadiusPx else curveRadiusPx
quadraticBezierTo(
x1 = previousLine.left, y1 = previousLine.top,
x2 = previousLine.left + normalizedCurveRadius, y2 = currentLine.bottom
)
lineTo(currentLine.left - normalizedCurveRadius, currentLine.bottom)
quadraticBezierTo(
x1 = currentLine.left, y1 = currentLine.bottom,
x2 = currentLine.left, y2 = currentLine.bottom - curveRadiusPx
)
}
else -> {
cubicTo(
x1 = previousLine.left, y1 = previousLine.top,
x2 = currentLine.left, y2 = currentLine.bottom,
x3 = currentLine.left, y3 = currentLine.bottom - curveRadiusPx
)
}
}
lineTo(currentLine.left, currentLine.top + curveRadiusPx)
previousLine = currentLine
}
close()
}
return Outline.Generic(shapePath)
}
}
/**
* Interface defining the method to calculate the radius for the corners of a text shape.
*/
interface TextShapeCorners {
/**
* Calculates the corner radius based on the provided density and text style.
*
* @param density The density of the screen.
* @param textStyle The style of the text.
* @return The calculated radius in pixels.
*/
fun calculateRadius(density: Density, textStyle: TextStyle): Float
/**
* Implementation of [TextShapeCorners] with a fixed radius.
*
* @property radius The fixed radius in Dp.
*/
data class Fixed(private val radius: Dp) : TextShapeCorners {
override fun calculateRadius(density: Density, textStyle: TextStyle): Float = with(density) {
return radius.toPx()
}
}
/**
* Implementation of [TextShapeCorners] with a flexible radius based on a fraction of the line height.
*
* @property fraction The fraction of the line height to use for the radius. Must be between 0.0 and 0.5.
*/
data class Flexible(
@FloatRange(0.0, 0.5)
private val fraction: Float = 0.45f
) : TextShapeCorners {
override fun calculateRadius(density: Density, textStyle: TextStyle): Float = with(density) {
return textStyle.lineHeight.toPx() * fraction
}
}
}
/**
* Interface defining the method to calculate the padding for a text shape.
*/
interface TextShapePadding {
/**
* Calculates the padding based on the provided density and text style.
*
* @param density The density of the screen.
* @param textStyle The style of the text.
* @return The calculated padding in pixels.
*/
fun calculatePadding(density: Density, textStyle: TextStyle): Float
/**
* Implementation of [TextShapePadding] with a fixed padding.
*
* @property padding The fixed padding in Dp.
*/
data class Fixed(private val padding: Dp) : TextShapePadding {
override fun calculatePadding(density: Density, textStyle: TextStyle): Float = with(density) {
return padding.toPx()
}
}
/**
* Implementation of [TextShapePadding] with flexible padding based on the difference between line height and font size.
*/
data object Flexible : TextShapePadding {
override fun calculatePadding(density: Density, textStyle: TextStyle): Float = with(density) {
return textStyle.lineHeight.toPx() - textStyle.fontSize.toPx()
}
}
}
/**
* Extension function to add horizontal padding to a rectangle.
*
* @param padding The amount of padding to add.
* @return A new [Rect] with the added padding.
*/
private fun Rect.addHorizontalPadding(padding: Float): Rect {
return this.copy(left - padding, top, right + padding, bottom)
}
/**
* Extension function to get the rectangle bounds of a line of text.
*
* @param lineIndex The index of the line.
* @return A [Rect] representing the bounds of the line.
*/
private fun TextLayoutResult.getLineRect(lineIndex: Int): Rect {
return Rect(getLineLeft(lineIndex), getLineTop(lineIndex), getLineRight(lineIndex), getLineBottom(lineIndex))
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment