Разработка
Как создать кастомный угловой бейдж в Jetpack Compose
В этой статье мы рассмотрим, как создать настраиваемый угловой бейдж в Jetpack Compose. Этот значок может быть особенно полезен для отображения скидок, ярлыков новых товаров или любых других отметок.
В этой статье мы рассмотрим, как создать настраиваемый угловой бейдж в Jetpack Compose. Этот значок может быть особенно полезен для отображения скидок, ярлыков новых товаров или любых других отметок. Мы также позаботимся о том, чтобы содержимое значка было полностью настраиваемым.
Давайте погрузимся в работу!
Идея
Идея заключается в том, чтобы создать значок в виде полоски, который будет располагаться в углу компонента, повернутый на 45°, с настраиваемыми свойствами. Чтобы добавить глубины, мы нарисуем две дополнительные полоски позади.
Вот схематичная иллюстрация:
Создание кастомной формы
Сначала определим кастомный Shape под названием CornerBadgeShape, который позволит нам создать полосу с регулируемой округлостью:
class CornerBadgeShape(
@FloatRange(0.0, 1.0)
private val cornerRoundness: Float,
) : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val (width, height) = size
val cornerLength = computeAdjustedCornerLength(width, height, cornerRoundness)
val path = Path().apply {
moveTo(0f, height)
// 1
lineTo(x = height - cornerLength / 2, y = cornerLength / 2)
// 2
cubicTo(
x1 = height, y1 = 0f,
x2 = height + cornerLength / 3, y2 = 0f,
x3 = height + cornerLength, y3 = 0f
)
// 3
lineTo(x = width - height - cornerLength, y = 0f)
// 4
cubicTo(
x1 = width - height - cornerLength / 3, y1 = 0f,
x2 = width - height, y2 = 0f,
x3 = width - height + cornerLength / 2, y3 = cornerLength / 2
)
// 5
lineTo(x = width, height)
// 6
cubicTo(
x1 = width - cornerLength / 2, y1 = height - cornerLength / 2,
x2 = width - height * 0.66f, y2 = height - cornerLength / 2,
x3 = width - height, y3 = height - cornerLength / 2
)
// 7
lineTo(x = height, y = height - cornerLength / 2)
// 8
cubicTo(
x1 = height * 0.66f, y1 = height - cornerLength / 2,
x2 = cornerLength / 2, y2 = height - cornerLength / 2,
x3 = 0f, y3 = height
)
close()
}
return Outline.Generic(path)
}
companion object {
private const val BASE_CORNER_RATIO = 1f / 3f
fun computeAdjustedCornerLength(width: Float, height: Float, cornerRoundness: Float): Float {
val targetCornerLength = height * BASE_CORNER_RATIO * cornerRoundness.coerceIn(0f, 1f)
val maxCornerLength = computeMaxCornerFitLength(width, height).coerceAtLeast(0f)
return targetCornerLength.coerceAtMost(maxCornerLength)
}
private fun computeMaxCornerFitLength(width: Float, height: Float): Float {
return (width - height * 2) / 2
}
}
}
Схема рисования фигуры:
Углы бейджа
Чтобы расположить бейдж в любом из четырех углов, мы определим sealed класс BadgeCorner, который представляет каждый вариант угла вместе с информацией о его выравнивании и зеркальном отображении:
sealed class BadgeCorner( val alignment: Alignment, val layoutScaleX: Float, val layoutScaleY: Float, val innerScaleX: Float, val innerScaleY: Float ) { object TopLeft: BadgeCorner(Alignment.TopStart, 1f, 1f, 1f, 1f) object TopRight: BadgeCorner(Alignment.TopEnd, -1f, 1f, -1f, 1f) object BottomLeft: BadgeCorner(Alignment.BottomStart, 1f, -1f, 1f, -1f) object BottomRight: BadgeCorner(Alignment.BottomEnd, -1f, -1f, -1f, -1f) }
Мы используем scaleX = -1 для горизонтального зеркального отображения и scaleY = -1 для вертикального зеркального отображения, чтобы соответствующим образом перевернуть бейдж в зависимости от угла. Чтобы сохранить правильную ориентацию содержимого внутри бейджа, мы соответствующим образом изменяем innerScale, чтобы противодействовать этому зеркальному отражению.
Создание композабл
Теперь давайте определим составной CornerBadgeBox, который объединяет все вместе:
@Composable
fun CornerBadgeBox(
badgeBrush: Brush,
backBadgeBrush: Brush,
modifier: Modifier = Modifier,
corner: BadgeCorner = BadgeCorner.TopRight,
contentPadding: Dp = 4.dp,
stripThickness: Dp = 32.dp,
cornerPadding: Dp = 32.dp,
cornerRoundness: Float = 0.5f,
badgeContent: @Composable BoxScope.() -> Unit,
content: @Composable BoxScope.() -> Unit
) {
// Implementation goes here
}
Параметры:
badgeBrush— кисть, используемая для рисования видимой полосы бейджа;backBadgeBrush— кисть, используемая для декоративных задних полос;modifier— модификатор для стилизации внешнего контейнера;corner— указывает угол, в котором отображается бейдж;contentPadding— расстояние между краями содержимого и бейджем;stripThickness— толщина полос бейджа. Видимая толщина может казаться меньше из-за примененного закругления углов;cornerPadding— расстояние от угла до начала бейджа;cornerRoundness— округлость углов бейджа (от 0f до 1f);badgeContent— Composable содержимое внутри бейджа;content— основное содержимое контейнера.
Реализация макета
Прежде чем приступить к созданию макета, давайте определим служебную функцию для вычисления диагонали квадрата. Она поможет нам определить размер бейджа и рассчитать нужную подложку:
private fun squareDiagonal(side: Dp): Dp {
return squareDiagonal(side.value).dp
}
private fun squareDiagonal(side: Float): Float {
return side * sqrt(2f)
}
Теперь мы можем приступить к реализации макета внутри композита CornerBadgeBox.
Это включает в себя соответствующее позиционирование задних линий бейджа, основного контента и главного бейджа на переднем плане с помощью композабл Layout и применение необходимых преобразований.
Посмотрите сниппет:
// Define the badge shape
val badgeShape = remember(cornerRoundness) { CornerBadgeShape(cornerRoundness) }
// Pre-calculate a padding that occurs because of the roundness of the strip
val stripRoundnessPadding = remember(stripThickness, cornerRoundness, contentPadding, cornerPadding) {
val badgeSideLength = squareDiagonal(stripThickness)
val badgeWidth = squareDiagonal(contentPadding + cornerPadding + badgeSideLength)
CornerBadgeShape.computeAdjustedCornerLength(badgeWidth.value, stripThickness.value, cornerRoundness).dp / 2
}
Layout(
modifier = modifier,
content = {
// Side back badge: rotated 45° and mirrored vertically
Box(
Modifier
.layoutId(LayoutId.SideBackBadge)
.scale(corner.layoutScaleX, corner.layoutScaleY)
.graphicsLayer {
transformOrigin = TransformOrigin(0f, 1f)
rotationZ = 45f
scaleY = -1f
}
.background(backBadgeBrush, badgeShape)
)
// Top back badge: rotated 45° without mirroring
Box(
Modifier
.layoutId(LayoutId.TopBackBadge)
.scale(corner.layoutScaleX, corner.layoutScaleY)
.graphicsLayer {
transformOrigin = TransformOrigin(0f, 1f)
rotationZ = 45f
}
.background(backBadgeBrush, badgeShape)
)
// Main content of the box
Box(
modifier = Modifier.layoutId(LayoutId.Content),
content = content
)
// Foreground badge on top of the content
Box(
content = badgeContent,
contentAlignment = Alignment.Center,
modifier = Modifier
.layoutId(LayoutId.Badge)
.scale(corner.layoutScaleX, corner.layoutScaleY)
.graphicsLayer {
transformOrigin = TransformOrigin(0f, 1f)
rotationZ = -45f
}
.background(badgeBrush, badgeShape)
.clip(badgeShape)
// Add bottom padding to account for corner roundness
.padding(bottom = stripRoundnessPadding)
.scale(corner.innerScaleX, corner.innerScaleY)
)
}
) { measurables, constraints ->
// Convert dimensions from Dp to pixels
val contentPaddingPx = contentPadding.roundToPx()
val stripThicknessPx = stripThickness.roundToPx()
val cornerPaddingPx = cornerPadding.roundToPx()
// Calculate the side length of the strip when rotated 45°
val badgeSideLength = squareDiagonal(stripThicknessPx.toFloat())
// Calculate the diagonal width of the badge strip’s bounding square
val badgeWidth = squareDiagonal(contentPaddingPx + cornerPaddingPx + badgeSideLength)
// Calculate the padding that should be applied to the back badges from the corner
val stripRoundnessPadding = CornerBadgeShape.computeAdjustedCornerLength(badgeWidth, stripThicknessPx.toFloat(), cornerRoundness) / 2
val backBadgeCornerPadding = contentPaddingPx + cornerPaddingPx + squareDiagonal(stripRoundnessPadding)
// Measure all components
val contentPlaceable = measurables.first { it.layoutId == LayoutId.Content }.measure(constraints)
val badgePlaceable = measurables.first { it.layoutId == LayoutId.Badge }.measure(
Constraints.fixed(badgeWidth.toInt(), stripThicknessPx)
)
val sideBackBadgePlaceable = measurables.first { it.layoutId == LayoutId.SideBackBadge }.measure(
Constraints.fixed(badgeWidth.toInt(), stripThicknessPx)
)
val topBackBadgePlaceable = measurables.first { it.layoutId == LayoutId.TopBackBadge }.measure(
Constraints.fixed(badgeWidth.toInt(), stripThicknessPx)
)
val layoutWidth = contentPlaceable.width
val layoutHeight = contentPlaceable.height
layout(layoutWidth, layoutHeight) {
// Compute the badge position based on corner alignment
val badgePosition = corner.alignment.align(
size = IntSize(badgePlaceable.width, badgePlaceable.height),
space = IntSize(layoutWidth + contentPaddingPx * 2, layoutHeight + contentPaddingPx * 2),
layoutDirection = LayoutDirection.Ltr
) - IntOffset(contentPaddingPx, contentPaddingPx)
// Place the side back badge
sideBackBadgePlaceable.placeWithLayer(badgePosition) {
translationY = (-stripThicknessPx + backBadgeCornerPadding) * corner.layoutScaleY
}
// Place the top back badge
topBackBadgePlaceable.placeWithLayer(badgePosition) {
translationY = -stripThicknessPx * corner.layoutScaleY
translationX = backBadgeCornerPadding * corner.layoutScaleX
}
// Place the main content
contentPlaceable.placeRelative(0, 0)
// Place the foreground badge
badgePlaceable.placeWithLayer(badgePosition) {
val offset = contentPaddingPx + cornerPaddingPx + badgeSideLength
translationY = (-stripThicknessPx + offset) * corner.layoutScaleY
}
}
}
Мы закончили! Если вам нужен полный код, то посмотрите его на на GitHub.
Практическое использование
Давайте рассмотрим несколько практических примеров.
Простой значок скидки
Самый простой вариант использования — это отображение короткой текстовой надписи — например, скидки в данном случае:
CornerBadgeBox(
badgeBrush = SolidColor(Color(0xFFFFB907)),
backBadgeBrush = SolidColor(Color(0xFFD9951C)),
stripThickness = 32.dp,
cornerPadding = 32.dp,
contentPadding = 4.dp,
cornerRoundness = 0.5f,
corner = BadgeCorner.TopRight,
badgeContent = {
Text(
text = "34% OFF",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
) {
Column(
Modifier
.shadow(6.dp, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Image(
painter = painterResource(R.drawable.apple),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(200.dp, 230.dp)
)
Text(
text = "Apple",
fontSize = 16.sp,
modifier = Modifier.padding(8.dp)
)
}
}
Анимированный бейдж распродажи
Мы также можем создать анимированный значок «SALE», расширив предыдущий пример и применив простой модификатор basicMarquee:
CornerBadgeBox(
// Same parameters as before
corner = BadgeCorner.TopLeft,
badgeContent = {
Text(
text = "SALE • SALE • SALE • SALE • SALE • SALE • ",
fontSize = 12.sp,
maxLines = 1,
modifier = Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 0,
spacing = MarqueeSpacing.fractionOfContainer(0f),
velocity = 12.dp
)
)
}
) {
// Same content as before
}
Бейдж избранного
Еще один вариант использования — создание отметки для отмеченных элементов:
CornerBadgeBox(
badgeBrush = SolidColor(Color(0xFFF89D44)),
backBadgeBrush = SolidColor(Color(0xFFBD6919)),
corner = BadgeCorner.TopRight,
stripThickness = 42.dp,
cornerPadding = 4.dp,
contentPadding = 2.dp,
cornerRoundness = 0.5f,
badgeContent = {
Icon(
imageVector = Icons.Rounded.Star,
contentDescription = null,
tint = Color(0xFFFD5F0B),
modifier = Modifier
.size(28.dp)
.align(Alignment.Center)
.rotate(-45f)
)
}
) {
Column(
Modifier
.shadow(6.dp, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Image(
painter = painterResource(R.drawable.banana),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(200.dp, 230.dp)
)
Text(
text = "Banana",
fontSize = 16.sp,
modifier = Modifier.padding(8.dp)
)
}
}
Возможно, вы придумаете еще более креативные способы его использования — получайте удовольствие.
-
Аналитика магазинов4 недели назад
Мобильный рынок Ближнего Востока: исследование Bidease и Sensor Tower выявляет драйверы роста
-
Видео и подкасты для разработчиков3 недели назад
Разбор кода: iOS-приложение для управления личными финансами на Swift. Часть 1
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.47
-
Разработка4 недели назад
100 уроков о том, как я довёл своё приложение до продажи за семизначную сумму






