Connect with us

Разработка

Оборачиваем любой 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,  
)

Начальная и конечная точки будут соответственно левым и правым краями представления. А радиус будет равен половине его высоты плюс некоторое дополнительное пространство для обводки. Если вы хотите вертикальную ленту, переверните эти измерения.

Оборачиваем любой Composable красивой ленточкой

Нарисовав ее над нашим элементом, мы получаем ленту нужных размеров, но ей не хватает глубины. Попробуем сделать так, чтобы лента как бы обертывалась вокруг кнопки.

Добавление глубины

Как видите, секрет создания этого эффекта заключается в разделении контура на две части, а затем рисовании одной части над кнопкой, а другой — под ней.

Один из способов сделать это — разделить контур на сегменты, выпуклые вверх, и сегменты, выпуклые вниз.

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

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: