Освоение различных видов линий в Jetpack Compose с помощью PathEffect
Что это, азбука Морзе?

При создании пользовательского интерфейса в Jetpack Compose нам часто приходится рисовать линии. Иногда они используются как разделители для разбиения пространства или эстетические компоненты более сложных форм или дизайна. Разработчики не всегда применяют прямые непрерывные линии. Точки или тире выглядят менее строго, чем сплошная черта, а забавные формы или стилизованные концы линий придадут дизайну особую изысканность.

Если вы хотите, чтобы ваши линии были похожи на азбуку Морзе, состоящую из точек и тире, читайте дальше, чтобы узнать все секреты стилизации линий!

Основы

Итак, мы определили общую цель. Нарисовать базовую линию в Jetpack Compose можно следующим образом:

@Composable
fun SolidLine(color: Color, modifier: Modifier = Modifier) {
Canvas(modifier) {
drawLine(
color = color,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
strokeWidth = LINE_WIDTH.toPx()
)
}
}

В нашем случае линия горизонтальна, она находится внутри компонента Canvas и имеет начальное и конечное смещение Offset. strokeWidth (ширина штриха) устанавливается в пиксельное значение (в моем случае преобразованное из dp), и мы можем задать цвет. Более интересные цветовые эффекты достигаются с помощью версии, использующей инструмент Brush вместо сплошного цвета.

Предельно простая и скучная прямая линия

Координаты конечной точки начинаются с (0,0) в левом верхнем углу, а правый нижний угол  —  это точка Canvas (width,height).

Источник

Для получения базовых знаний о том, как рисование на Canvas работает в Jetpack Compose и DrawScope, можете ознакомиться с официальной документацией.

Итак, у нас есть линия. Как сделать ее чуть более привлекательной?

Оформление концов линии

Первое, что мы можем сделать, это придать линии более изысканное оформление, изменив стиль ее концов.

В нашем распоряжении есть следующие варианты.

  • StrokeCap.Butt: по умолчанию, без расширения, просто прямоугольные концы.
  • StrokeCap.Rounded: расширяет линию, оформляя каждый конец в виде полукруга.
  • StrokeCap.Square: также расширяет линию, завершая ее прямоугольной формой (выглядит так же, как Butt, но в Butt нет расширения).
StrokeCap.Butt (черная линия), StrokeCap.Rounded (красная линия) и StrokeCap.Square (синяя линия)

Важно отметить, что эти расширения выходят за границы Canvas. На изображении выше видно, что красная и синяя линии длиннее черной, хотя их composable-компоненты Canvas имеют одинаковый размер.

Описанные стили можно применять к линии с помощью аргумента cap:

@Composable
fun SolidLineRoundedEnds(color: Color, modifier: Modifier = Modifier) {
Canvas(modifier) {
drawLine(
color = color,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
cap = StrokeCap.Round,
strokeWidth = LINE_WIDTH.toPx()
)
}
}

Перейдем к более продвинутой стилизации

Итак, мы усвоили основы. Теперь применим PathEffect, чтобы сделать линию более интересной.

Можно использовать эффект dashPathEffect, который позволяет указать промежуток времени, в течение которого будет видно тире (показывать линию) и когда оно будет “отключаться” (не показывать линию). Вы можете задать эти интервалы произвольно. Тут также используются пиксельные значения, поэтому, принимая во внимание плотность пикселей экрана конкретного устройства, следует использовать параметр with(LocalDensity.current) для преобразования dp в px.

@Composable
fun DashedLine(color: Color, modifier: Modifier = Modifier) {
val density = LocalDensity.current
with(density) {
val dashOnInterval = (LINE_WIDTH * 4).toPx()
val dashOffInterval = (LINE_WIDTH * 4).toPx()

val pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(dashOnInterval, dashOffInterval),
phase = 0f
)
Canvas(modifier) {
drawLine(
color = color,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
strokeWidth = LINE_WIDTH.toPx(),
cap = StrokeCap.Round,
pathEffect = pathEffect,
)
}
}
}

Вы наверняка заметили значение phase. Оно указывает, откуда следует начинать рисовать линию: ноль рисует первое тире, другие значения позволят начать рисовку с разных точек.

Phase: 0, dashOnInterval/2 и dashOnInterval. Я снова установил концы линий в StrokeCap.Butt, чтобы более рельефно показать phase-эффект

Азбука Морзе

Немного усложним оформление с использованием тире, добавив больше интервалов в dashPathEffect:

@Composable
fun MultiDashedLine(color: Color, modifier: Modifier = Modifier) {
val density = LocalDensity.current
with(density) {
val dashOnInterval1 = (LINE_WIDTH * 4).toPx()
val dashOffInterval1 = (LINE_WIDTH * 2).toPx()
val dashOnInterval2 = (LINE_WIDTH / 4).toPx()
val dashOffInterval2 = (LINE_WIDTH * 2).toPx()

val pathEffect =
PathEffect.dashPathEffect(
intervals = floatArrayOf(dashOnInterval1, dashOffInterval1, dashOnInterval2, dashOffInterval2),
phase = 0f
)
Canvas(modifier) {
drawLine(
color = color,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
strokeWidth = LINE_WIDTH.toPx(),
cap = StrokeCap.Round,
pathEffect = pathEffect,
)
}
}
}

Это даст такую интересную линию:

Перейдем к “штамповке”

Не хотите, чтобы линия состояла из других линий? Можете “наштамповать” определенные фигуры вдоль линии с помощью stampedPathEffect. Создаем нужную фигуру, затем устанавливаем расстояния между такими фигурами (параметр advance; также используем phase, как и раньше, и StampedPathEffectStyle):

@Composable
fun DottedLine(color: Color, modifier: Modifier = Modifier) {
val dotRadius = LINE_WIDTH / 2
val dotSpacing = dotRadius * 2

Canvas(modifier) {
val circle = Path()
circle.addOval(Rect(center = Offset.Zero, radius = dotRadius.toPx()))
val pathEffect = PathEffect.stampedPathEffect(
shape = circle,
advance = dotSpacing.toPx(),
phase = 0f,
style = StampedPathEffectStyle.Translate
)
drawLine(
color = color,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
pathEffect = pathEffect,
cap = StrokeCap.Round,
strokeWidth = dotRadius.toPx()
)
}
}

Мы выбрали фигуру “круг”, и у нас получилась линия из круглых точек!

StampedPathEffectStyle.Translate будет перемещаться вдоль линии и продолжать отображать фигуру в том же виде, в котором она была определена. К другим типам StampedPathEffectStyle вернемся позже. При отображении прямой линии они не применяются.

Чтобы получить еще более причудливую линию, добавьте немного креатива при создании фигур:

@Composable
fun HeartLine(color: Color, modifier: Modifier = Modifier) {
val shapeRadius = LINE_WIDTH / 2
val dotSpacing = shapeRadius * 4
val density = LocalDensity.current

val heartPath = remember {
with(density) {
Path().apply {
val width = (shapeRadius * 2).toPx()
val height = (shapeRadius * 2).toPx()
moveTo(width / 2, height / 4)
cubicTo(width / 4, 0f, 0f, height / 3, width / 4, height / 2)
lineTo(width / 2, height * 3 / 4)
lineTo(width * 3 / 4, height / 2)
cubicTo(width, height / 3, width * 3 / 4, 0f, width / 2, height / 4)
}
}
}
Canvas(modifier) {
val pathEffect = PathEffect.stampedPathEffect(
shape = heartPath,
advance = dotSpacing.toPx(),
phase = 0f,
style = StampedPathEffectStyle.Translate
)
drawLine(
color = color,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
pathEffect = pathEffect,
strokeWidth = shapeRadius.toPx()
)
}
}

Зигзаги и волны

Как нам сделать такую линию?

Можно просто создать контур и в цикле вычислить и нарисовать все зигзаги как точки. А можно просто создать фигуру контура один раз, а затем “штамповать” этот контур, как мы делали выше.

Сначала создадим фигуру и “наштампуем” ее вдоль линии (используя advance той же ширины, что и вся фигура):

@Composable
fun ZigZagLine(color: Color, modifier: Modifier = Modifier) {
val shapeWidth = LINE_WIDTH
val density = LocalDensity.current
val zigZagPath = remember {
with(density) {
Path().apply {
val zigZagWidth = shapeWidth.toPx()
val zigZagHeight = shapeWidth.toPx()
moveTo(0f, 0f)
lineTo(zigZagWidth / 2, zigZagHeight)
lineTo(zigZagWidth, 0f)
}
}
}
Canvas(modifier) {
val pathEffect = PathEffect.stampedPathEffect(
shape = zigZagPath,
advance = shapeWidth.toPx(),
phase = 0f,
style = StampedPathEffectStyle.Translate
)
drawLine(
color = color,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
pathEffect = pathEffect,
strokeWidth = shapeWidth.toPx()
)
}
}

Получаем неожиданный (хотя и не лишенный привлекательности) результат:

Не совсем то, что мы хотели получить!

Как правило, если при рисовании линии мы используем контур, можно просто применить drawPath со стилем (style) DrawStyle.Stroke и получить линии в виде зигзагов. Но поскольку мы задействуем форму, она автоматически заполняется. Чтобы исправить эту ситуацию, нам нужно закрыть контур:

val zigZagPath = remember {
with(density) {
Path().apply {
val zigZagWidth = shapeWidth.toPx()
val zigZagHeight = shapeWidth.toPx()
val zigZagLineWidth = (1.dp).toPx()
moveTo(0f, 0f)
lineTo(zigZagWidth / 2, zigZagHeight)
lineTo(zigZagWidth, 0f)
lineTo(zigZagWidth, 0f + zigZagLineWidth)
lineTo(zigZagWidth / 2, zigZagHeight + zigZagLineWidth)
lineTo(0f, 0f + zigZagLineWidth)
}
}
}

Преобразуя все это в линию, получаем следующий результат:

Вы можете заметить, что фактическая линия проведена из точки (0,0), поэтому зигзагообразная фигура находится ниже и простирается от конца фактической линии. Покажем это более наглядно, добавив сюда еще и контур прямой линии:

Если вам нужна точность, можете добавить к контуру перемещение с помощью translate с Offset, чтобы контур рисовался точно по нужной линии:

val zigZagPath = remember {
with(density) {
Path().apply {
val zigZagWidth = shapeWidth.toPx()
val zigZagHeight = shapeWidth.toPx()
val zigZagLineWidth = (1.dp).toPx()
val shapeVerticalOffset = (zigZagHeight / 2) / 2
val shapeHorizontalOffset = (zigZagHeight / 2) / 2
moveTo(0f, 0f)
lineTo(zigZagWidth / 2, zigZagHeight / 2)
lineTo(zigZagWidth, 0f)
lineTo(zigZagWidth, 0f + zigZagLineWidth)
lineTo(zigZagWidth / 2, zigZagHeight / 2 + zigZagLineWidth)
lineTo(0f, 0f + zigZagLineWidth)
translate(Offset(-shapeHorizontalOffset, -shapeVerticalOffset))
}
}
}

Теперь получаем желаемый результат:

Линия выходит за горизонтальные края. При желании это можно исправить, добавив на Canvas ограничительную рамку

Полный код для зигзагообразной линии:

@Composable
fun ZigZagLine(color: Color, modifier: Modifier = Modifier) {
val shapeWidth = LINE_WIDTH
val density = LocalDensity.current
val zigZagPath = remember {
with(density) {
Path().apply {
val zigZagWidth = shapeWidth.toPx()
val zigZagHeight = shapeWidth.toPx()
val zigZagLineWidth = (1.dp).toPx()
val shapeVerticalOffset = (zigZagHeight / 2) / 2
val shapeHorizontalOffset = (zigZagHeight / 2) / 2
moveTo(0f, 0f)
lineTo(zigZagWidth / 2, zigZagHeight / 2)
lineTo(zigZagWidth, 0f)
lineTo(zigZagWidth, 0f + zigZagLineWidth)
lineTo(zigZagWidth / 2, zigZagHeight / 2 + zigZagLineWidth)
lineTo(0f, 0f + zigZagLineWidth)
translate(Offset(-shapeHorizontalOffset, -shapeVerticalOffset))
}
}
}
Canvas(modifier) {
val pathEffect = PathEffect.stampedPathEffect(
shape = zigZagPath,
advance = shapeWidth.toPx(),
phase = 0f,
style = StampedPathEffectStyle.Translate
)
drawLine(
color = color,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
pathEffect = pathEffect,
strokeWidth = shapeWidth.toPx()
)
}
}

Не только прямые линии

Мы научились рисовать прямые линии! А что, если нам нужно превратить линии в фигуры? Тогда просто используйте drawRect, drawCircle или drawPath, чтобы получить необходимую фигуру:

На моем GitHub есть код примеров для таких фигур

Что происходит в углах?

Мы уже рассматривали инструмент StampedPathEffectStyle. Он полезен в тех случаях, когда вы рисуете не только прямые линии. Он указывает контуру, созданному путем “штампования”, что делать, когда линия меняет направление.

Так, мы можем увидеть различные стили при использовании контура из сердечек:

StampedPathEffectStyle.Translate, StampedPathEffectStyle.Rotate и StampedPathEffectStyle.Morph

Translate  —  это прямое перемещение. Поскольку я не исправил смещение для формы “сердечко”, моя форма не следует точному контуру прямоугольника, а как бы выступает за определенные пределы. Стиль Rotate вращает фигуру, что исправляет эту проблему, но теперь получаем следующий результат: одни сердечки расположены ближе друг к другу, а другие  —  дальше. В случае с Morph это легче продемонстрировать на примере зигзагообразного контура:

И снова StampedPathEffectStyle.Translate, StampedPathEffectStyle.Rotate и StampedPathEffectStyle.Morph

Здесь разница более заметна. Функция вращения хорошо справляется с контурами из сердечек, а в зигзагах, где “штампованные” фигуры соединены, по углам видны зазоры (а в некоторых местах и накладки). Morph исправляет эту проблему, трансформируя форму по мере ее прохождения через углы и создавая впечатление более ровной линии (в особенности заметен такой эффект в левом нижнем углу). Взгляните на пример контура из сердечек: сердечки в углах становятся немного меньше, и это позволяет им огибать углы.

Линии в Jetpack Compose? Легко!

Итак, вы ознакомились со множеством способов стилизации линий в Jetpack Compose, а также с тем, как их можно использовать для создания интересных форм и контуров.

Разрабатывайте забавные образцы азбуки Морзе с линиями и фигурами, состоящими из точек и тире, и отправляйте секретные послания своим пользователям!

Все примеры кода, использованные в этой статье, можно найти на Github.

Особо внимательные читатели наверняка заметили, что в статье не был затронут PathEffect.chainPathEffect. Честно говоря, у меня не получилось привести достойный пример его использования или найти случаи, где бы его применяли другие (даже с помощью ИИ-модели Gemini!).

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

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


Перевод статьи Katie Barnett: Dot. Dash. Design. Mastering Lines in Jetpack Compose with PathEffect

Предыдущая статьяБорьба с веб-скрейперами с помощью Rust
Следующая статьяВстроенные инструменты Golang