Разработка
Как создать кастомный угловой бейдж в 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) ) } }
Возможно, вы придумаете еще более креативные способы его использования — получайте удовольствие.
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.22
-
Новости2 недели назад
Видео и подкасты о мобильной разработке 2025.24
-
Вовлечение пользователей4 недели назад
Небольшое изменение в интерфейсе Duolingo, которое меняет все
-
Маркетинг и монетизация4 недели назад
Институциональные покупки: понимание и обнаружение