Разработка
Оборачиваем любой Composable красивой ленточкой
В этой небольшой статье мы узнаем, как создать модификатор ленты, который рисует контур, обтекающий любой элемент интерфейса Compose.
В этой небольшой статье мы узнаем, как создать модификатор ленты, который рисует контур, обтекающий любой элемент интерфейса Compose. Этот эффект можно использовать в качестве эффекта наведения курсора на нужную кнопку или для привлечения внимания к новому действию.
Рисование ленты
Начнем с простого рисования извилистой линии. В недавнем видео я рассказал об использовании полярных координат для определения путей.
Я создал функции расширения polarLineTo и polarMoveTo, которые принимают в качестве аргументов градус, радиус и начало координат, необходимые для построения точек в полярной системе координат.
Мы можем использовать эти функции для рисования линии, напоминающей ленту, задавая ей увеличивающийся градус и перемещая начало координат.
private fun createRibbon(
start: Offset,
end: Offset,
radius: Float,
loops: Float = 5f,
startAngle: Float = 90f,
resolution: Int = 1000
): Path {
val ribbon = Path()
ribbon.moveTo(start)
(0..resolution).forEach { i ->
val t = i / resolution.toFloat()
val min = min(startAngle, (360f * loops) - startAngle)
val max = max(startAngle, (360f * loops) - startAngle)
val degree = lerp(
start = min,
stop = max,
fraction = t
)
if (i == 0) {
ribbon.polarMoveTo(
degrees = degree,
distance = radius,
origin = start
)
}
ribbon.polarLineTo(
degrees = degree,
distance = radius,
origin = lerp(
start = start,
stop = end,
fraction = t,
),
)
}
return ribbon
}
Эта функция принимает начальную и конечную точки ленты, а также другие детали, такие как радиус дуг и количество петель, и возвращает путь, который мы можем нарисовать.
Эта функция по сути перетаскивает окружность вдоль линии, одновременно нанося точки по ее окружности.
С помощью этой функции мы можем создать ленту, соответствующую размерам любого элемента, а затем нарисовать ее.
val loops = loops - .5f // Trim the ribbon by 180º to cut the "tail"
val ribbonPath = createRibbon(
start = Offset(0f, size.height * .5f),
end = Offset(size.width, size.height * .5f),
radius = (size.height * .5f) + stroke.toPx(),
startAngle = -90f,
loops = loops,
)
Начальная и конечная точки будут соответственно левым и правым краями представления. А радиус будет равен половине его высоты плюс некоторое дополнительное пространство для обводки. Если вы хотите вертикальную ленту, переверните эти измерения.
Нарисовав ее над нашим элементом, мы получаем ленту нужных размеров, но ей не хватает глубины. Попробуем сделать так, чтобы лента как бы обертывалась вокруг кнопки.
Добавление глубины
Как видите, секрет создания этого эффекта заключается в разделении контура на две части, а затем рисовании одной части над кнопкой, а другой — под ней.
Один из способов сделать это — разделить контур на сегменты, выпуклые вверх, и сегменты, выпуклые вниз.
val measure = PathMeasure()
measure.setPath(ribbonPath, false)
var isPositive = measure.getTangent(0f).y > 0f
val distanceArray = mutableListOf<Float>()
var segmentStartDistance = 0f
val resolution = 500
for (i in 0..resolution) {
val t = i / resolution.toFloat()
val distance = t * measure.length
val tan = measure.getTangent(distance)
val currentIsPositive = tan.y > 0f
if (currentIsPositive != isPositive) {
val segmentLength = distance - segmentStartDistance
distanceArray.add(segmentLength)
segmentStartDistance = distance
isPositive = currentIsPositive
} else if (i == resolution) {
val segmentLength = distance - segmentStartDistance
distanceArray.add(segmentLength)
}
}
Этот код создает массив distanceArray, в котором каждый элемент представляет длину отрезка, полностью наклоненного вверх или вниз.
Для этого он проходит по траектории ленты и проверяет касательную в каждой точке. Затем он проверяет значение y касательной и проверяет, изменилось ли оно с положительного на отрицательное.
Используя этот массив distanceArray, мы можем нарисовать ленту по частям внутри onDrawWithContent, что позволяет определить z-глубину нашего контента.
onDrawWithContent {
drawPath(
path = ribbonPath,
color = color,
style = Stroke(
width = stroke.toPx(),
pathEffect = PathEffect.dashPathEffect(
intervals = distanceArray.toFloatArray(),
),
)
)
drawContent()
drawPath(
path = ribbonPath,
color = color,
style = Stroke(
width = stroke.toPx(),
pathEffect = PathEffect.dashPathEffect(
intervals = distanceArray.toFloatArray(),
phase = distanceArray.first()
),
)
)
}
Сначала мы рисуем ribbonPath с применением distanceArray в качестве intervals в эффекте прерывистого пути. Это позволит нарисовать только те сегменты пути, которые выпуклы вниз.
Затем мы рисуем содержимое прямо посередине с помощью функции drawContent.
Наконец, мы рисуем сегменты, которые выпуклы вверх. Мы по-прежнему используем тот же distanceArray, но устанавливаем phase равной длине первого сегмента. Это смещает эффект, чтобы нарисовать только оставшиеся сегменты.
И с помощью этого мы можем поместить всю эту логику в аккуратный модификатор, который мы сможем применять везде, где это необходимо.
Вы можете найти полный код ниже.
Если у вас есть какие-либо вопросы или вы создали что-то интересное с помощью этого, пожалуйста, сообщите мне об этом ниже. Также, если у вас есть какие-либо мысли или критические замечания по поводу видео, я с удовольствием их выслушаю.
Спасибо за чтение и удачи!
Код:
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
private fun Modifier.ribbon(
brush: Brush,
stroke: Dp = 4.dp,
loops: Int = 7,
progress: Float = 1f,
): Modifier {
return drawWithCache {
val loops = loops - .5f
val ribbonPath = createRibbon(
start = Offset(0f, size.height * .5f),
end = Offset(size.width, size.height * .5f),
radius = (size.height * .5f) + stroke.toPx(),
startAngle = -90f,
loops = loops,
)
val measure = PathMeasure()
measure.setPath(ribbonPath, false)
var isPositive = measure.getTangent(0f).y > 0f
val distanceArray = mutableListOf<Float>()
var segmentStartDistance = 0f
val resolution = 500
for (i in 0..resolution) {
val t = i / resolution.toFloat()
val distance = t * measure.length
val tan = measure.getTangent(distance)
val currentIsPositive = tan.y > 0f
if (currentIsPositive != isPositive) {
val segmentLength = distance - segmentStartDistance
distanceArray.add(segmentLength)
segmentStartDistance = distance
isPositive = currentIsPositive
} else if (i == resolution) {
val segmentLength = distance - segmentStartDistance
distanceArray.add(segmentLength)
}
}
onDrawWithContent {
if (progress > 0f)
drawPath(
path = ribbonPath,
brush = brush,
style = Stroke(
width = stroke.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round,
pathEffect = PathEffect.chainPathEffect(
PathEffect.dashPathEffect(
intervals = distanceArray.toFloatArray(),
),
PathEffect.dashPathEffect(
intervals = floatArrayOf(
measure.length * progress,
measure.length,
)
)
)
)
)
drawContent()
if (progress > 0f)
drawPath(
path = ribbonPath,
brush = brush,
style = Stroke(
width = stroke.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round,
pathEffect = PathEffect.chainPathEffect(
PathEffect.dashPathEffect(
intervals = distanceArray.toFloatArray(),
phase = distanceArray.first()
),
PathEffect.dashPathEffect(
intervals = floatArrayOf(
measure.length * progress,
measure.length,
)
)
)
)
)
}
}
}
private fun createRibbon(
start: Offset,
end: Offset,
radius: Float,
startAngle: Float = 90f,
loops: Float = 5f,
resolution: Int = 1000
): Path {
val ribbon = Path()
ribbon.moveTo(start)
(0..resolution).forEach { i ->
val t = i / resolution.toFloat()
val min = min(startAngle, (360f * loops) - startAngle)
val max = max(startAngle, (360f * loops) - startAngle)
val degree = lerp(
start = min,
stop = max,
fraction = t
)
if (i == 0) {
ribbon.polarMoveTo(
degrees = degree,
distance = radius,
origin = start
)
}
ribbon.polarLineTo(
degrees = degree,
distance = radius,
origin = androidx.compose.ui.geometry.lerp(
start = start,
stop = end,
fraction = t,
),
)
}
return ribbon
}
private fun Path.polarMoveTo(
degrees: Float,
distance: Float,
origin: Offset = Offset.Zero
) = moveTo(polarToCart(degrees, distance, origin))
private fun Path.polarLineTo(
degrees: Float,
distance: Float,
origin: Offset = Offset.Zero
) = lineTo(polarToCart(degrees, distance, origin))
private fun polarToCart(
degrees: Float,
distance: Float,
origin: Offset = Offset.Zero
): Offset = Offset(
x = distance * cos(-degrees * (PI / 180)).toFloat(),
y = distance * sin(-degrees * (PI / 180)).toFloat(),
) + origin
-
Аналитика магазинов4 недели назад
Мобильный рынок Ближнего Востока: исследование Bidease и Sensor Tower выявляет драйверы роста
-
Видео и подкасты для разработчиков4 недели назад
Разбор кода: iOS-приложение для управления личными финансами на Swift. Часть 1
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.47
-
Разработка4 недели назад
100 уроков о том, как я довёл своё приложение до продажи за семизначную сумму


