Рассмотрим, как создать форму текстового фона в 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. Эту форму можно использовать в различных сценариях, например в модификаторах backgroundborder и 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.

Процесс довольно сложный, поэтому разобьем его на четыре этапа:

  1. Рисование верхней линии и ее угловых кривых.
  2. Рисование правых сторон линий.
  3. Рисование нижней линии и ее угловых кривых.
  4. Рисование левых сторон линий.

Подготовка к рисованию

Перейдем к методу 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
}
)

Отлично!

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Kappdev: How to Create Text Background Shape in Jetpack Compose

Предыдущая статьяОзнакомление с функциями высшего порядка в Kotlin