Рассмотрим, как создать форму текстового фона в Jetpack Compose. Такая форма поможет творчески подойти к улучшению внешнего вида текста в различных сценариях, о которых поговорим в последнем разделе.
Подготовка
Прежде чем приступить к реализации, обсудим некоторые подготовительные моменты.
TextShapeCorners
Этот интерфейс задает радиус кривых фигуры либо в dp
(Fixed
), либо относительно высоты линии, умноженной на дробь (Flexible
).
interface TextShapeCorners {
fun calculateRadius(density: Density, textStyle: TextStyle): Float
data class Fixed(private val radius: Dp) : TextShapeCorners {
override fun calculateRadius(density: Density, textStyle: TextStyle): Float = with(density) {
return radius.toPx()
}
}
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
}
}
}
TextShapePadding
Этот интерфейс задает горизонтальный отступ от строк (от начала и конца). Он включает две опции: Fixed
(задается вручную в dp
) и Flexible
(вычисляется как разница между высотой строки и размером шрифта).
interface TextShapePadding {
fun calculatePadding(density: Density, textStyle: TextStyle): Float
data class Fixed(private val padding: Dp) : TextShapePadding {
override fun calculatePadding(density: Density, textStyle: TextStyle): Float = with(density) {
return padding.toPx()
}
}
data object Flexible : TextShapePadding {
override fun calculatePadding(density: Density, textStyle: TextStyle): Float = with(density) {
return textStyle.lineHeight.toPx() - textStyle.fontSize.toPx()
}
}
}
Другие полезные функции
Для упрощения кода понадобятся две вспомогательные функции.
Первая — это функция расширения TextLayoutResult
, которая извлекает прямоугольник для строки с указанным индексом:
fun TextLayoutResult.getLineRect(lineIndex: Int): Rect {
return Rect(getLineLeft(lineIndex), getLineTop(lineIndex), getLineRight(lineIndex), getLineBottom(lineIndex))
}
Вторая — функция расширения для Rect
, которая расширяет его границы на заданный горизонтальный отступ:
fun Rect.addHorizontalPadding(padding: Float): Rect {
return this.copy(left - padding, top, right + padding, bottom)
}
Определение
Чтобы сделать код гибким и многократно используемым, определим его как пользовательскую форму Shape
. Эту форму можно использовать в различных сценариях, например в модификаторах background
, border
и shadow
.
Определим класс, который реализует интерфейс 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 {
// Реализация
}
}
Параметры
textLayoutResult
: предоставляет полную информацию о макете текста.padding
: горизонтальные отступы, применяемые к текстовым строкам.corners
: радиус кривых, используемых для рисования формы.
Реализация
Реализация заключается в рисовании Path
с использованием информации о границах строки, полученной из TextLayoutResult
.
Процесс довольно сложный, поэтому разобьем его на четыре этапа:
- Рисование верхней линии и ее угловых кривых.
- Рисование правых сторон линий.
- Рисование нижней линии и ее угловых кривых.
- Рисование левых сторон линий.
Подготовка к рисованию
Перейдем к методу createOutline
и проведем подготовительную работу перед рисованием:
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 {
// Логика рисования Path.
}
return Outline.Generic(shapePath)
}
А теперь начнем рисовать Path.
Шаг 1
Начнем с левого верхнего угла первой линии, затем проведем линию к правому верхнему углу и нарисуем сам угол, а затем начальную линию для следующего шага.
// Получаем прямоугольник первой линии
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)
Шаг 2
На этом шаге проводим итерацию по всем линиям и рисуем их правые стороны.
Если разница между концами линии больше, чем 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
}
Шаг 3
Теперь рисуем правый нижний угол последней линии, затем соединяем его с левым нижним углом, рисуем сам угол и начинаем линию для следующего шага.
// Нижняя линия и ее углы
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)
Шаг 4
Последний шаг для рисования пути (Path).
Перебираем оставшиеся линии в обратном порядке, от второй последней линии к первой, и рисуем их левые стороны.
Здесь применяем ту же стратегию построения кривых, что и в шаге 2, но с разницей для левых сторон.
// Итерация по строкам в обратном порядке для левой стороны
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
}
// Закрываем path
close()
Поздравляю! Мы успешно построили то, что хотели. Полный код реализации вы можете найти на GitHub Gist. Теперь посмотрим, как можно применить это на практике.
Практическое использование
В этом разделе рассмотрим два примера использования такой формы.
Простой фон
С помощью этой фигуры мы можем нарисовать простой фон за текстовыми строками:
// Переменная состояния для хранения TextLayoutResult, инициализируется как null
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
// Производное состояние, создающее экземпляр TextShape на основе текущего результата textLayoutResult
val textShape by remember {
derivedStateOf { textLayoutResult?.let { TextShape(it) } }
}
Text(
text = "Lorem Ipsum\nis simply dummy text of\nthe printing\nand\ntypesetting industry.",
modifier = Modifier
// Условное добавление фоновой фигуры, если textShape не null
.then(
other = textShape?.let {
Modifier.background(MaterialTheme.colorScheme.primary, it)
} ?: Modifier
),
textAlign = TextAlign.Center,
color = Color.White,
fontSize = 16.sp,
lineHeight = 25.sp,
fontWeight = FontWeight.Medium,
// Дополнительная стилизация текста для центрирования текста в соответствии с высотой строки
style = LocalTextStyle.current.copy(
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None),
platformStyle = PlatformTextStyle(includeFontPadding = false)
),
onTextLayout = {
// Обновляйте результат textLayoutResult при каждом изменении макета текста
textLayoutResult = it
}
)
Выглядит хорошо!
Эффект нажатия на текст
Более продвинутый вариант использования предусматривает создание пользовательского эффекта нажатия на текст с помощью этой формы:
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
val textShape by remember {
derivedStateOf { textLayoutResult?.let { TextShape(it) } }
}
// Переменная состояния для отслеживания нажатия на текст
var isPressed by remember { mutableStateOf(false) }
// Анимация фона и альфы границы на основе состояния isPressed
val backgroundAlpha by animateFloatAsState(if (isPressed) 0.2f else 0f)
val borderAlpha by animateFloatAsState(if (isPressed) 1f else 0f)
Text(
text = "Lorem Ipsum\nis simply dummy text of\nthe printing\nand\ntypesetting industry.",
modifier = Modifier
// Добавьте детектор жестов касания для обновления состояния isPressed
.pointerInput(Unit) {
this.detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
delay(350)
isPressed = false
}
)
}
// Условное добавление фона и границы
.then(
other = textShape?.let {
Modifier
.background(Color.Blue.copy(backgroundAlpha), it)
.border(width = 0.72.dp, Color.Blue.copy(borderAlpha), it)
} ?: Modifier
),
textAlign = TextAlign.Center,
color = Color.White,
fontSize = 16.sp,
lineHeight = 25.sp,
fontWeight = FontWeight.Medium,
style = LocalTextStyle.current.copy(
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None),
platformStyle = PlatformTextStyle(includeFontPadding = false)
),
onTextLayout = {
textLayoutResult = it
}
)
Отлично!
Читайте также:
- Как создать 3D-границу в Jetpack Compose
- Ознакомление с Work Manager в Android
- Базовый класс Android ViewModel за 5 минут
Читайте нас в Telegram, VK и Дзен
Перевод статьи Kappdev: How to Create Text Background Shape in Jetpack Compose