В этой небольшой статье мы узнаем, как создать модификатор ленты, который рисует контур, обтекающий любой элемент интерфейса 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