С помощью Compose вы можете создавать фигуры, состоящие из многоугольников. Например, вы можете создавать следующие типы фигур:

Чтобы создать пользовательский скругленный многоугольник в Compose, добавьте зависимость graphics-shapes
в ваш app/build.gradle
:
implementation "androidx.graphics:graphics-shapes:1.0.1"
Эта библиотека позволяет создавать фигуры, состоящие из многоугольников. В то время как многоугольные фигуры имеют только прямые края и острые углы, эти фигуры допускают опциональные скругленные углы. Это упрощает морфинг между двумя разными фигурами. Морфинг между произвольными фигурами сложен и, как правило, является проблемой времени проектирования. Но эта библиотека упрощает его, осуществляя морфинг между фигурами с похожими многоугольными структурами.
Создать полигоны
Следующий фрагмент кода создает базовую многоугольную форму с 6 точками в центре области рисования:
Box( modifier = Modifier .drawWithCache { val roundedPolygon = RoundedPolygon( numVertices = 6, radius = size.minDimension / 2, centerX = size.width / 2, centerY = size.height / 2 ) val roundedPolygonPath = roundedPolygon.toPath().asComposePath() onDrawBehind { drawPath(roundedPolygonPath, color = Color.Blue) } } .fillMaxSize() )

В этом примере библиотека создает RoundedPolygon
, который содержит геометрию, представляющую запрошенную форму. Чтобы нарисовать эту форму в приложении Compose, необходимо получить из нее объект Path
, чтобы преобразовать форму в форму, которую Compose умеет рисовать.
Закруглить углы многоугольника
Чтобы скруглить углы многоугольника, используйте параметр CornerRounding
. Он принимает два параметра: radius
и smoothing
. Каждый скругленный угол состоит из 1-3 кубических кривых, центр которых имеет форму дуги окружности, а две боковые («фланкирующие») кривые переходят от края фигуры к центральной кривой.
Радиус
radius
— это радиус окружности, используемой для округления вершины.
Например, следующий треугольник с закругленными углами создается следующим образом:


r
определяет размер кругового скругления скругленных углов.Сглаживание
Smoothing — это фактор, который определяет, сколько времени требуется, чтобы перейти от круглой части скругления угла к краю. Фактор сглаживания 0 (несглаженный, значение по умолчанию для CornerRounding
) приводит к чисто круглому скруглению угла. Фактор сглаживания, отличный от нуля (до максимального значения 1,0), приводит к скруглению угла тремя отдельными кривыми.


Например, приведенный ниже фрагмент кода иллюстрирует тонкую разницу между установкой сглаживания на 0 и 1:
Box( modifier = Modifier .drawWithCache { val roundedPolygon = RoundedPolygon( numVertices = 3, radius = size.minDimension / 2, centerX = size.width / 2, centerY = size.height / 2, rounding = CornerRounding( size.minDimension / 10f, smoothing = 0.1f ) ) val roundedPolygonPath = roundedPolygon.toPath().asComposePath() onDrawBehind { drawPath(roundedPolygonPath, color = Color.Black) } } .size(100.dp) )

Размер и положение
По умолчанию фигура создается с радиусом 1
вокруг центра ( 0, 0
). Этот радиус представляет собой расстояние между центром и внешними вершинами многоугольника, на котором основана фигура. Обратите внимание, что скругление углов приводит к уменьшению фигуры, поскольку скругленные углы будут ближе к центру, чем скругляемые вершины. Чтобы изменить размер многоугольника, отрегулируйте значение radius
. Чтобы изменить положение, измените centerX
или centerY
многоугольника. В качестве альтернативы преобразуйте объект, чтобы изменить его размер, положение и поворот, используя стандартные функции преобразования DrawScope
, такие как DrawScope#translate()
.
Формы морфинга
Объект Morph
— это новая форма, представляющая анимацию между двумя многоугольными формами. Для морфинга между двумя формами создайте два RoundedPolygons
и объект Morph
, который принимает эти две формы. Для вычисления формы между начальной и конечной формами укажите значение progress
от нуля до единицы, чтобы определить ее форму между начальной (0) и конечной (1) формами:
Box( modifier = Modifier .drawWithCache { val triangle = RoundedPolygon( numVertices = 3, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f, rounding = CornerRounding( size.minDimension / 10f, smoothing = 0.1f ) ) val square = RoundedPolygon( numVertices = 4, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f ) val morph = Morph(start = triangle, end = square) val morphPath = morph .toPath(progress = 0.5f).asComposePath() onDrawBehind { drawPath(morphPath, color = Color.Black) } } .fillMaxSize() )
В приведенном выше примере прогресс находится точно посередине между двумя фигурами (скругленный треугольник и квадрат), что дает следующий результат:

В большинстве сценариев морфинг выполняется как часть анимации, а не просто статического рендеринга. Для анимации между этими двумя можно использовать стандартные API анимации в Compose, чтобы изменять значение прогресса с течением времени. Например, можно бесконечно анимировать морфинг между этими двумя формами следующим образом:
val infiniteAnimation = rememberInfiniteTransition(label = "infinite animation") val morphProgress = infiniteAnimation.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( tween(500), repeatMode = RepeatMode.Reverse ), label = "morph" ) Box( modifier = Modifier .drawWithCache { val triangle = RoundedPolygon( numVertices = 3, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f, rounding = CornerRounding( size.minDimension / 10f, smoothing = 0.1f ) ) val square = RoundedPolygon( numVertices = 4, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f ) val morph = Morph(start = triangle, end = square) val morphPath = morph .toPath(progress = morphProgress.value) .asComposePath() onDrawBehind { drawPath(morphPath, color = Color.Black) } } .fillMaxSize() )

Использовать многоугольник как клип
Модификатор clip
в Compose часто используется для изменения способа рендеринга компонуемого объекта и использования теней, которые рисуются вокруг области отсечения:
fun RoundedPolygon.getBounds() = calculateBounds().let { Rect(it[0], it[1], it[2], it[3]) } class RoundedPolygonShape( private val polygon: RoundedPolygon, private var matrix: Matrix = Matrix() ) : Shape { private var path = Path() override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { path.rewind() path = polygon.toPath().asComposePath() matrix.reset() val bounds = polygon.getBounds() val maxDimension = max(bounds.width, bounds.height) matrix.scale(size.width / maxDimension, size.height / maxDimension) matrix.translate(-bounds.left, -bounds.top) path.transform(matrix) return Outline.Generic(path) } }
Затем вы можете использовать многоугольник в качестве клипа, как показано в следующем фрагменте:
val hexagon = remember { RoundedPolygon( 6, rounding = CornerRounding(0.2f) ) } val clip = remember(hexagon) { RoundedPolygonShape(polygon = hexagon) } Box( modifier = Modifier .clip(clip) .background(MaterialTheme.colorScheme.secondary) .size(200.dp) ) { Text( "Hello Compose", color = MaterialTheme.colorScheme.onSecondary, modifier = Modifier.align(Alignment.Center) ) }
Это приводит к следующему:

Это может выглядеть не так уж и отличающимся от того, что отображалось раньше, но это позволяет использовать другие функции Compose. Например, этот метод можно использовать для обрезки изображения и применения тени вокруг обрезанной области:
val hexagon = remember { RoundedPolygon( 6, rounding = CornerRounding(0.2f) ) } val clip = remember(hexagon) { RoundedPolygonShape(polygon = hexagon) } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Image( painter = painterResource(id = R.drawable.dog), contentDescription = "Dog", contentScale = ContentScale.Crop, modifier = Modifier .graphicsLayer { this.shadowElevation = 6.dp.toPx() this.shape = clip this.clip = true this.ambientShadowColor = Color.Black this.spotShadowColor = Color.Black } .size(200.dp) ) }

Кнопка морфинга при нажатии
Вы можете использовать библиотеку graphics-shape
для создания кнопки, которая при нажатии трансформируется между двумя формами. Сначала создайте MorphPolygonShape
, который расширяет Shape
, масштабируя и транслируя его для соответствующего размещения. Обратите внимание на передачу прогресса, чтобы форму можно было анимировать:
class MorphPolygonShape( private val morph: Morph, private val percentage: Float ) : Shape { private val matrix = Matrix() override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y. matrix.scale(size.width / 2f, size.height / 2f) matrix.translate(1f, 1f) val path = morph.toPath(progress = percentage).asComposePath() path.transform(matrix) return Outline.Generic(path) } }
Чтобы использовать эту форму морфа, создайте два полигона, shapeA
и shapeB
. Создайте и запомните Morph
. Затем примените морф к кнопке как контур клипа, используя interactionSource
при нажатии в качестве движущей силы анимации:
val shapeA = remember { RoundedPolygon( 6, rounding = CornerRounding(0.2f) ) } val shapeB = remember { RoundedPolygon.star( 6, rounding = CornerRounding(0.1f) ) } val morph = remember { Morph(shapeA, shapeB) } val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val animatedProgress = animateFloatAsState( targetValue = if (isPressed) 1f else 0f, label = "progress", animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessMedium) ) Box( modifier = Modifier .size(200.dp) .padding(8.dp) .clip(MorphPolygonShape(morph, animatedProgress.value)) .background(Color(0xFF80DEEA)) .size(200.dp) .clickable(interactionSource = interactionSource, indication = null) { } ) { Text("Hello", modifier = Modifier.align(Alignment.Center)) }
Это приводит к следующей анимации при нажатии на поле:

Анимированная форма, изменяющаяся бесконечно
Для бесконечной анимации морф-формы используйте rememberInfiniteTransition
. Ниже приведен пример изображения профиля, которое меняет форму (и вращается) бесконечно с течением времени. Этот подход использует небольшую корректировку MorphPolygonShape
, показанного выше:
class CustomRotatingMorphShape( private val morph: Morph, private val percentage: Float, private val rotation: Float ) : Shape { private val matrix = Matrix() override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y. matrix.scale(size.width / 2f, size.height / 2f) matrix.translate(1f, 1f) matrix.rotateZ(rotation) val path = morph.toPath(progress = percentage).asComposePath() path.transform(matrix) return Outline.Generic(path) } } @Preview @Composable private fun RotatingScallopedProfilePic() { val shapeA = remember { RoundedPolygon( 12, rounding = CornerRounding(0.2f) ) } val shapeB = remember { RoundedPolygon.star( 12, rounding = CornerRounding(0.2f) ) } val morph = remember { Morph(shapeA, shapeB) } val infiniteTransition = rememberInfiniteTransition("infinite outline movement") val animatedProgress = infiniteTransition.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( tween(2000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "animatedMorphProgress" ) val animatedRotation = infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( tween(6000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "animatedMorphProgress" ) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Image( painter = painterResource(id = R.drawable.dog), contentDescription = "Dog", contentScale = ContentScale.Crop, modifier = Modifier .clip( CustomRotatingMorphShape( morph, animatedProgress.value, animatedRotation.value ) ) .size(200.dp) ) } }
Этот код дает следующий забавный результат:

Пользовательские полигоны
Если формы, созданные из правильных многоугольников, не подходят для вашего варианта использования, вы можете создать более индивидуальную форму со списком вершин. Например, вы можете создать форму сердца, как здесь:

Вы можете указать отдельные вершины этой фигуры, используя перегрузку RoundedPolygon
, которая принимает массив с плавающей точкой из координат x, y.
Чтобы разбить многоугольник сердца, обратите внимание, что полярная система координат для указания точек упрощает задачу, чем использование декартовой системы координат (x, y), где 0°
начинается с правой стороны и продолжается по часовой стрелке, а 270°
находится в положении 12 часов:

Теперь форму можно определить проще, указав угол (𝜭) и радиус от центра в каждой точке:

Теперь вершины можно создать и передать в функцию RoundedPolygon
:
val vertices = remember { val radius = 1f val radiusSides = 0.8f val innerRadius = .1f floatArrayOf( radialToCartesian(radiusSides, 0f.toRadians()).x, radialToCartesian(radiusSides, 0f.toRadians()).y, radialToCartesian(radius, 90f.toRadians()).x, radialToCartesian(radius, 90f.toRadians()).y, radialToCartesian(radiusSides, 180f.toRadians()).x, radialToCartesian(radiusSides, 180f.toRadians()).y, radialToCartesian(radius, 250f.toRadians()).x, radialToCartesian(radius, 250f.toRadians()).y, radialToCartesian(innerRadius, 270f.toRadians()).x, radialToCartesian(innerRadius, 270f.toRadians()).y, radialToCartesian(radius, 290f.toRadians()).x, radialToCartesian(radius, 290f.toRadians()).y, ) }
Вершины необходимо перевести в декартовы координаты с помощью функции radialToCartesian
:
internal fun Float.toRadians() = this * PI.toFloat() / 180f internal val PointZero = PointF(0f, 0f) internal fun radialToCartesian( radius: Float, angleRadians: Float, center: PointF = PointZero ) = directionVectorPointF(angleRadians) * radius + center internal fun directionVectorPointF(angleRadians: Float) = PointF(cos(angleRadians), sin(angleRadians))
Предыдущий код дает вам необработанные вершины для сердца, но вам нужно скруглить определенные углы, чтобы получить выбранную форму сердца. Углы в 90°
и 270°
не имеют скругления, но другие углы имеют. Чтобы добиться пользовательского скругления для отдельных углов, используйте параметр perVertexRounding
:
val rounding = remember { val roundingNormal = 0.6f val roundingNone = 0f listOf( CornerRounding(roundingNormal), CornerRounding(roundingNone), CornerRounding(roundingNormal), CornerRounding(roundingNormal), CornerRounding(roundingNone), CornerRounding(roundingNormal), ) } val polygon = remember(vertices, rounding) { RoundedPolygon( vertices = vertices, perVertexRounding = rounding ) } Box( modifier = Modifier .drawWithCache { val roundedPolygonPath = polygon.toPath().asComposePath() onDrawBehind { scale(size.width * 0.5f, size.width * 0.5f) { translate(size.width * 0.5f, size.height * 0.5f) { drawPath(roundedPolygonPath, color = Color(0xFFF15087)) } } } } .size(400.dp) )
В результате получается розовое сердце:

Если предыдущие фигуры не подходят для вашего варианта использования, рассмотрите возможность использования класса Path
для рисования пользовательской фигуры или загрузки файла ImageVector
с диска. Библиотека graphics-shapes
не предназначена для использования с произвольными фигурами, а специально предназначена для упрощения создания скругленных многоугольников и анимаций морфинга между ними.
Дополнительные ресурсы
Дополнительную информацию и примеры можно найти на следующих ресурсах:
,С помощью Compose вы можете создавать фигуры, состоящие из многоугольников. Например, вы можете создавать следующие типы фигур:

Чтобы создать пользовательский скругленный многоугольник в Compose, добавьте зависимость graphics-shapes
в ваш app/build.gradle
:
implementation "androidx.graphics:graphics-shapes:1.0.1"
Эта библиотека позволяет создавать фигуры, состоящие из многоугольников. В то время как многоугольные фигуры имеют только прямые края и острые углы, эти фигуры допускают опциональные скругленные углы. Это упрощает морфинг между двумя разными фигурами. Морфинг между произвольными фигурами сложен и, как правило, является проблемой времени проектирования. Но эта библиотека упрощает его, осуществляя морфинг между фигурами с похожими многоугольными структурами.
Создать полигоны
Следующий фрагмент кода создает базовую многоугольную форму с 6 точками в центре области рисования:
Box( modifier = Modifier .drawWithCache { val roundedPolygon = RoundedPolygon( numVertices = 6, radius = size.minDimension / 2, centerX = size.width / 2, centerY = size.height / 2 ) val roundedPolygonPath = roundedPolygon.toPath().asComposePath() onDrawBehind { drawPath(roundedPolygonPath, color = Color.Blue) } } .fillMaxSize() )

В этом примере библиотека создает RoundedPolygon
, который содержит геометрию, представляющую запрошенную форму. Чтобы нарисовать эту форму в приложении Compose, необходимо получить из нее объект Path
, чтобы преобразовать форму в форму, которую Compose умеет рисовать.
Закруглить углы многоугольника
Чтобы скруглить углы многоугольника, используйте параметр CornerRounding
. Он принимает два параметра: radius
и smoothing
. Каждый скругленный угол состоит из 1-3 кубических кривых, центр которых имеет форму дуги окружности, а две боковые («фланкирующие») кривые переходят от края фигуры к центральной кривой.
Радиус
radius
— это радиус окружности, используемой для округления вершины.
Например, следующий треугольник с закругленными углами создается следующим образом:


r
определяет размер кругового скругления скругленных углов.Сглаживание
Smoothing — это фактор, который определяет, сколько времени требуется, чтобы перейти от круглой части скругления угла к краю. Фактор сглаживания 0 (несглаженный, значение по умолчанию для CornerRounding
) приводит к чисто круглому скруглению угла. Фактор сглаживания, отличный от нуля (до максимального значения 1,0), приводит к скруглению угла тремя отдельными кривыми.


Например, приведенный ниже фрагмент кода иллюстрирует тонкую разницу между установкой сглаживания на 0 и 1:
Box( modifier = Modifier .drawWithCache { val roundedPolygon = RoundedPolygon( numVertices = 3, radius = size.minDimension / 2, centerX = size.width / 2, centerY = size.height / 2, rounding = CornerRounding( size.minDimension / 10f, smoothing = 0.1f ) ) val roundedPolygonPath = roundedPolygon.toPath().asComposePath() onDrawBehind { drawPath(roundedPolygonPath, color = Color.Black) } } .size(100.dp) )

Размер и положение
По умолчанию фигура создается с радиусом 1
вокруг центра ( 0, 0
). Этот радиус представляет собой расстояние между центром и внешними вершинами многоугольника, на котором основана фигура. Обратите внимание, что скругление углов приводит к уменьшению фигуры, поскольку скругленные углы будут ближе к центру, чем скругляемые вершины. Чтобы изменить размер многоугольника, отрегулируйте значение radius
. Чтобы изменить положение, измените centerX
или centerY
многоугольника. В качестве альтернативы преобразуйте объект, чтобы изменить его размер, положение и поворот, используя стандартные функции преобразования DrawScope
, такие как DrawScope#translate()
.
Формы морфинга
Объект Morph
— это новая форма, представляющая анимацию между двумя многоугольными формами. Для морфинга между двумя формами создайте два RoundedPolygons
и объект Morph
, который принимает эти две формы. Для вычисления формы между начальной и конечной формами укажите значение progress
от нуля до единицы, чтобы определить ее форму между начальной (0) и конечной (1) формами:
Box( modifier = Modifier .drawWithCache { val triangle = RoundedPolygon( numVertices = 3, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f, rounding = CornerRounding( size.minDimension / 10f, smoothing = 0.1f ) ) val square = RoundedPolygon( numVertices = 4, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f ) val morph = Morph(start = triangle, end = square) val morphPath = morph .toPath(progress = 0.5f).asComposePath() onDrawBehind { drawPath(morphPath, color = Color.Black) } } .fillMaxSize() )
В приведенном выше примере прогресс находится точно посередине между двумя фигурами (скругленный треугольник и квадрат), что дает следующий результат:

В большинстве сценариев морфинг выполняется как часть анимации, а не просто статического рендеринга. Для анимации между этими двумя можно использовать стандартные API анимации в Compose, чтобы изменять значение прогресса с течением времени. Например, можно бесконечно анимировать морфинг между этими двумя формами следующим образом:
val infiniteAnimation = rememberInfiniteTransition(label = "infinite animation") val morphProgress = infiniteAnimation.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( tween(500), repeatMode = RepeatMode.Reverse ), label = "morph" ) Box( modifier = Modifier .drawWithCache { val triangle = RoundedPolygon( numVertices = 3, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f, rounding = CornerRounding( size.minDimension / 10f, smoothing = 0.1f ) ) val square = RoundedPolygon( numVertices = 4, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f ) val morph = Morph(start = triangle, end = square) val morphPath = morph .toPath(progress = morphProgress.value) .asComposePath() onDrawBehind { drawPath(morphPath, color = Color.Black) } } .fillMaxSize() )

Использовать многоугольник как клип
Модификатор clip
в Compose часто используется для изменения способа рендеринга компонуемого объекта и использования теней, которые рисуются вокруг области отсечения:
fun RoundedPolygon.getBounds() = calculateBounds().let { Rect(it[0], it[1], it[2], it[3]) } class RoundedPolygonShape( private val polygon: RoundedPolygon, private var matrix: Matrix = Matrix() ) : Shape { private var path = Path() override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { path.rewind() path = polygon.toPath().asComposePath() matrix.reset() val bounds = polygon.getBounds() val maxDimension = max(bounds.width, bounds.height) matrix.scale(size.width / maxDimension, size.height / maxDimension) matrix.translate(-bounds.left, -bounds.top) path.transform(matrix) return Outline.Generic(path) } }
Затем вы можете использовать многоугольник в качестве клипа, как показано в следующем фрагменте:
val hexagon = remember { RoundedPolygon( 6, rounding = CornerRounding(0.2f) ) } val clip = remember(hexagon) { RoundedPolygonShape(polygon = hexagon) } Box( modifier = Modifier .clip(clip) .background(MaterialTheme.colorScheme.secondary) .size(200.dp) ) { Text( "Hello Compose", color = MaterialTheme.colorScheme.onSecondary, modifier = Modifier.align(Alignment.Center) ) }
Это приводит к следующему:

Это может выглядеть не так уж и отличающимся от того, что отображалось раньше, но это позволяет использовать другие функции Compose. Например, этот метод можно использовать для обрезки изображения и применения тени вокруг обрезанной области:
val hexagon = remember { RoundedPolygon( 6, rounding = CornerRounding(0.2f) ) } val clip = remember(hexagon) { RoundedPolygonShape(polygon = hexagon) } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Image( painter = painterResource(id = R.drawable.dog), contentDescription = "Dog", contentScale = ContentScale.Crop, modifier = Modifier .graphicsLayer { this.shadowElevation = 6.dp.toPx() this.shape = clip this.clip = true this.ambientShadowColor = Color.Black this.spotShadowColor = Color.Black } .size(200.dp) ) }

Кнопка морфинга при нажатии
Вы можете использовать библиотеку graphics-shape
для создания кнопки, которая при нажатии трансформируется между двумя формами. Сначала создайте MorphPolygonShape
, который расширяет Shape
, масштабируя и транслируя его для соответствующего размещения. Обратите внимание на передачу прогресса, чтобы форму можно было анимировать:
class MorphPolygonShape( private val morph: Morph, private val percentage: Float ) : Shape { private val matrix = Matrix() override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y. matrix.scale(size.width / 2f, size.height / 2f) matrix.translate(1f, 1f) val path = morph.toPath(progress = percentage).asComposePath() path.transform(matrix) return Outline.Generic(path) } }
Чтобы использовать эту форму морфа, создайте два полигона, shapeA
и shapeB
. Создайте и запомните Morph
. Затем примените морф к кнопке как контур клипа, используя interactionSource
при нажатии в качестве движущей силы анимации:
val shapeA = remember { RoundedPolygon( 6, rounding = CornerRounding(0.2f) ) } val shapeB = remember { RoundedPolygon.star( 6, rounding = CornerRounding(0.1f) ) } val morph = remember { Morph(shapeA, shapeB) } val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val animatedProgress = animateFloatAsState( targetValue = if (isPressed) 1f else 0f, label = "progress", animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessMedium) ) Box( modifier = Modifier .size(200.dp) .padding(8.dp) .clip(MorphPolygonShape(morph, animatedProgress.value)) .background(Color(0xFF80DEEA)) .size(200.dp) .clickable(interactionSource = interactionSource, indication = null) { } ) { Text("Hello", modifier = Modifier.align(Alignment.Center)) }
Это приводит к следующей анимации при нажатии на поле:

Анимированная форма, изменяющаяся бесконечно
Для бесконечной анимации морф-формы используйте rememberInfiniteTransition
. Ниже приведен пример изображения профиля, которое меняет форму (и вращается) бесконечно с течением времени. Этот подход использует небольшую корректировку MorphPolygonShape
, показанного выше:
class CustomRotatingMorphShape( private val morph: Morph, private val percentage: Float, private val rotation: Float ) : Shape { private val matrix = Matrix() override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y. matrix.scale(size.width / 2f, size.height / 2f) matrix.translate(1f, 1f) matrix.rotateZ(rotation) val path = morph.toPath(progress = percentage).asComposePath() path.transform(matrix) return Outline.Generic(path) } } @Preview @Composable private fun RotatingScallopedProfilePic() { val shapeA = remember { RoundedPolygon( 12, rounding = CornerRounding(0.2f) ) } val shapeB = remember { RoundedPolygon.star( 12, rounding = CornerRounding(0.2f) ) } val morph = remember { Morph(shapeA, shapeB) } val infiniteTransition = rememberInfiniteTransition("infinite outline movement") val animatedProgress = infiniteTransition.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( tween(2000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "animatedMorphProgress" ) val animatedRotation = infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( tween(6000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "animatedMorphProgress" ) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Image( painter = painterResource(id = R.drawable.dog), contentDescription = "Dog", contentScale = ContentScale.Crop, modifier = Modifier .clip( CustomRotatingMorphShape( morph, animatedProgress.value, animatedRotation.value ) ) .size(200.dp) ) } }
Этот код дает следующий забавный результат:

Пользовательские полигоны
Если формы, созданные из правильных многоугольников, не подходят для вашего варианта использования, вы можете создать более индивидуальную форму со списком вершин. Например, вы можете создать форму сердца, как здесь:

Вы можете указать отдельные вершины этой фигуры, используя перегрузку RoundedPolygon
, которая принимает массив с плавающей точкой из координат x, y.
Чтобы разбить многоугольник сердца, обратите внимание, что полярная система координат для указания точек упрощает задачу, чем использование декартовой системы координат (x, y), где 0°
начинается с правой стороны и продолжается по часовой стрелке, а 270°
находится в положении 12 часов:

Теперь форму можно определить проще, указав угол (𝜭) и радиус от центра в каждой точке:

Теперь вершины можно создать и передать в функцию RoundedPolygon
:
val vertices = remember { val radius = 1f val radiusSides = 0.8f val innerRadius = .1f floatArrayOf( radialToCartesian(radiusSides, 0f.toRadians()).x, radialToCartesian(radiusSides, 0f.toRadians()).y, radialToCartesian(radius, 90f.toRadians()).x, radialToCartesian(radius, 90f.toRadians()).y, radialToCartesian(radiusSides, 180f.toRadians()).x, radialToCartesian(radiusSides, 180f.toRadians()).y, radialToCartesian(radius, 250f.toRadians()).x, radialToCartesian(radius, 250f.toRadians()).y, radialToCartesian(innerRadius, 270f.toRadians()).x, radialToCartesian(innerRadius, 270f.toRadians()).y, radialToCartesian(radius, 290f.toRadians()).x, radialToCartesian(radius, 290f.toRadians()).y, ) }
Вершины необходимо перевести в декартовы координаты с помощью функции radialToCartesian
:
internal fun Float.toRadians() = this * PI.toFloat() / 180f internal val PointZero = PointF(0f, 0f) internal fun radialToCartesian( radius: Float, angleRadians: Float, center: PointF = PointZero ) = directionVectorPointF(angleRadians) * radius + center internal fun directionVectorPointF(angleRadians: Float) = PointF(cos(angleRadians), sin(angleRadians))
Предыдущий код дает вам необработанные вершины для сердца, но вам нужно скруглить определенные углы, чтобы получить выбранную форму сердца. Углы в 90°
и 270°
не имеют скругления, но другие углы имеют. Чтобы добиться пользовательского скругления для отдельных углов, используйте параметр perVertexRounding
:
val rounding = remember { val roundingNormal = 0.6f val roundingNone = 0f listOf( CornerRounding(roundingNormal), CornerRounding(roundingNone), CornerRounding(roundingNormal), CornerRounding(roundingNormal), CornerRounding(roundingNone), CornerRounding(roundingNormal), ) } val polygon = remember(vertices, rounding) { RoundedPolygon( vertices = vertices, perVertexRounding = rounding ) } Box( modifier = Modifier .drawWithCache { val roundedPolygonPath = polygon.toPath().asComposePath() onDrawBehind { scale(size.width * 0.5f, size.width * 0.5f) { translate(size.width * 0.5f, size.height * 0.5f) { drawPath(roundedPolygonPath, color = Color(0xFFF15087)) } } } } .size(400.dp) )
В результате получается розовое сердце:

Если предыдущие фигуры не подходят для вашего варианта использования, рассмотрите возможность использования класса Path
для рисования пользовательской фигуры или загрузки файла ImageVector
с диска. Библиотека graphics-shapes
не предназначена для использования с произвольными фигурами, а специально предназначена для упрощения создания скругленных многоугольников и анимаций морфинга между ними.
Дополнительные ресурсы
Дополнительную информацию и примеры можно найти на следующих ресурсах:
,С помощью Compose вы можете создавать фигуры, состоящие из многоугольников. Например, вы можете создавать следующие типы фигур:

Чтобы создать пользовательский скругленный многоугольник в Compose, добавьте зависимость graphics-shapes
в ваш app/build.gradle
:
implementation "androidx.graphics:graphics-shapes:1.0.1"
Эта библиотека позволяет создавать фигуры, состоящие из многоугольников. В то время как многоугольные фигуры имеют только прямые края и острые углы, эти фигуры допускают опциональные скругленные углы. Это упрощает морфинг между двумя разными фигурами. Морфинг между произвольными фигурами сложен и, как правило, является проблемой времени проектирования. Но эта библиотека упрощает его, осуществляя морфинг между фигурами с похожими многоугольными структурами.
Создать полигоны
Следующий фрагмент кода создает базовую многоугольную форму с 6 точками в центре области рисования:
Box( modifier = Modifier .drawWithCache { val roundedPolygon = RoundedPolygon( numVertices = 6, radius = size.minDimension / 2, centerX = size.width / 2, centerY = size.height / 2 ) val roundedPolygonPath = roundedPolygon.toPath().asComposePath() onDrawBehind { drawPath(roundedPolygonPath, color = Color.Blue) } } .fillMaxSize() )

В этом примере библиотека создает RoundedPolygon
, который содержит геометрию, представляющую запрошенную форму. Чтобы нарисовать эту форму в приложении Compose, необходимо получить из нее объект Path
, чтобы преобразовать форму в форму, которую Compose умеет рисовать.
Закруглить углы многоугольника
Чтобы скруглить углы многоугольника, используйте параметр CornerRounding
. Он принимает два параметра: radius
и smoothing
. Каждый скругленный угол состоит из 1-3 кубических кривых, центр которых имеет форму дуги окружности, а две боковые («фланкирующие») кривые переходят от края фигуры к центральной кривой.
Радиус
radius
— это радиус окружности, используемой для округления вершины.
Например, следующий треугольник с закругленными углами создается следующим образом:


r
определяет размер кругового скругления скругленных углов.Сглаживание
Smoothing — это фактор, который определяет, сколько времени требуется, чтобы перейти от круглой части скругления угла к краю. Фактор сглаживания 0 (несглаженный, значение по умолчанию для CornerRounding
) приводит к чисто круглому скруглению угла. Фактор сглаживания, отличный от нуля (до максимального значения 1,0), приводит к скруглению угла тремя отдельными кривыми.


Например, приведенный ниже фрагмент кода иллюстрирует тонкую разницу между установкой сглаживания на 0 и 1:
Box( modifier = Modifier .drawWithCache { val roundedPolygon = RoundedPolygon( numVertices = 3, radius = size.minDimension / 2, centerX = size.width / 2, centerY = size.height / 2, rounding = CornerRounding( size.minDimension / 10f, smoothing = 0.1f ) ) val roundedPolygonPath = roundedPolygon.toPath().asComposePath() onDrawBehind { drawPath(roundedPolygonPath, color = Color.Black) } } .size(100.dp) )

Размер и положение
По умолчанию фигура создается с радиусом 1
вокруг центра ( 0, 0
). Этот радиус представляет собой расстояние между центром и внешними вершинами многоугольника, на котором основана фигура. Обратите внимание, что скругление углов приводит к уменьшению фигуры, поскольку скругленные углы будут ближе к центру, чем скругляемые вершины. Чтобы изменить размер многоугольника, отрегулируйте значение radius
. Чтобы изменить положение, измените centerX
или centerY
многоугольника. В качестве альтернативы преобразуйте объект, чтобы изменить его размер, положение и поворот, используя стандартные функции преобразования DrawScope
, такие как DrawScope#translate()
.
Формы морфинга
Объект Morph
— это новая форма, представляющая анимацию между двумя многоугольными формами. Для морфинга между двумя формами создайте два RoundedPolygons
и объект Morph
, который принимает эти две формы. Для вычисления формы между начальной и конечной формами укажите значение progress
от нуля до единицы, чтобы определить ее форму между начальной (0) и конечной (1) формами:
Box( modifier = Modifier .drawWithCache { val triangle = RoundedPolygon( numVertices = 3, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f, rounding = CornerRounding( size.minDimension / 10f, smoothing = 0.1f ) ) val square = RoundedPolygon( numVertices = 4, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f ) val morph = Morph(start = triangle, end = square) val morphPath = morph .toPath(progress = 0.5f).asComposePath() onDrawBehind { drawPath(morphPath, color = Color.Black) } } .fillMaxSize() )
В приведенном выше примере прогресс находится точно посередине между двумя фигурами (скругленный треугольник и квадрат), что дает следующий результат:

В большинстве сценариев морфинг выполняется как часть анимации, а не просто статического рендеринга. Для анимации между этими двумя можно использовать стандартные API анимации в Compose, чтобы изменять значение прогресса с течением времени. Например, можно бесконечно анимировать морфинг между этими двумя формами следующим образом:
val infiniteAnimation = rememberInfiniteTransition(label = "infinite animation") val morphProgress = infiniteAnimation.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( tween(500), repeatMode = RepeatMode.Reverse ), label = "morph" ) Box( modifier = Modifier .drawWithCache { val triangle = RoundedPolygon( numVertices = 3, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f, rounding = CornerRounding( size.minDimension / 10f, smoothing = 0.1f ) ) val square = RoundedPolygon( numVertices = 4, radius = size.minDimension / 2f, centerX = size.width / 2f, centerY = size.height / 2f ) val morph = Morph(start = triangle, end = square) val morphPath = morph .toPath(progress = morphProgress.value) .asComposePath() onDrawBehind { drawPath(morphPath, color = Color.Black) } } .fillMaxSize() )

Использовать многоугольник как клип
Модификатор clip
в Compose часто используется для изменения способа рендеринга компонуемого объекта и использования теней, которые рисуются вокруг области отсечения:
fun RoundedPolygon.getBounds() = calculateBounds().let { Rect(it[0], it[1], it[2], it[3]) } class RoundedPolygonShape( private val polygon: RoundedPolygon, private var matrix: Matrix = Matrix() ) : Shape { private var path = Path() override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { path.rewind() path = polygon.toPath().asComposePath() matrix.reset() val bounds = polygon.getBounds() val maxDimension = max(bounds.width, bounds.height) matrix.scale(size.width / maxDimension, size.height / maxDimension) matrix.translate(-bounds.left, -bounds.top) path.transform(matrix) return Outline.Generic(path) } }
Затем вы можете использовать многоугольник в качестве клипа, как показано в следующем фрагменте:
val hexagon = remember { RoundedPolygon( 6, rounding = CornerRounding(0.2f) ) } val clip = remember(hexagon) { RoundedPolygonShape(polygon = hexagon) } Box( modifier = Modifier .clip(clip) .background(MaterialTheme.colorScheme.secondary) .size(200.dp) ) { Text( "Hello Compose", color = MaterialTheme.colorScheme.onSecondary, modifier = Modifier.align(Alignment.Center) ) }
Это приводит к следующему:

Это может выглядеть не так уж и отличающимся от того, что отображалось раньше, но это позволяет использовать другие функции Compose. Например, этот метод можно использовать для обрезки изображения и применения тени вокруг обрезанной области:
val hexagon = remember { RoundedPolygon( 6, rounding = CornerRounding(0.2f) ) } val clip = remember(hexagon) { RoundedPolygonShape(polygon = hexagon) } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Image( painter = painterResource(id = R.drawable.dog), contentDescription = "Dog", contentScale = ContentScale.Crop, modifier = Modifier .graphicsLayer { this.shadowElevation = 6.dp.toPx() this.shape = clip this.clip = true this.ambientShadowColor = Color.Black this.spotShadowColor = Color.Black } .size(200.dp) ) }

Кнопка морфинга при нажатии
Вы можете использовать библиотеку graphics-shape
для создания кнопки, которая при нажатии трансформируется между двумя формами. Сначала создайте MorphPolygonShape
, который расширяет Shape
, масштабируя и транслируя его для соответствующего размещения. Обратите внимание на передачу прогресса, чтобы форму можно было анимировать:
class MorphPolygonShape( private val morph: Morph, private val percentage: Float ) : Shape { private val matrix = Matrix() override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y. matrix.scale(size.width / 2f, size.height / 2f) matrix.translate(1f, 1f) val path = morph.toPath(progress = percentage).asComposePath() path.transform(matrix) return Outline.Generic(path) } }
Чтобы использовать эту форму морфа, создайте два полигона, shapeA
и shapeB
. Создайте и запомните Morph
. Затем примените морф к кнопке как контур клипа, используя interactionSource
при нажатии в качестве движущей силы анимации:
val shapeA = remember { RoundedPolygon( 6, rounding = CornerRounding(0.2f) ) } val shapeB = remember { RoundedPolygon.star( 6, rounding = CornerRounding(0.1f) ) } val morph = remember { Morph(shapeA, shapeB) } val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val animatedProgress = animateFloatAsState( targetValue = if (isPressed) 1f else 0f, label = "progress", animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessMedium) ) Box( modifier = Modifier .size(200.dp) .padding(8.dp) .clip(MorphPolygonShape(morph, animatedProgress.value)) .background(Color(0xFF80DEEA)) .size(200.dp) .clickable(interactionSource = interactionSource, indication = null) { } ) { Text("Hello", modifier = Modifier.align(Alignment.Center)) }
Это приводит к следующей анимации при нажатии на поле:

Анимированная форма, изменяющаяся бесконечно
Для бесконечной анимации морф-формы используйте rememberInfiniteTransition
. Ниже приведен пример изображения профиля, которое меняет форму (и вращается) бесконечно с течением времени. Этот подход использует небольшую корректировку MorphPolygonShape
, показанного выше:
class CustomRotatingMorphShape( private val morph: Morph, private val percentage: Float, private val rotation: Float ) : Shape { private val matrix = Matrix() override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y. matrix.scale(size.width / 2f, size.height / 2f) matrix.translate(1f, 1f) matrix.rotateZ(rotation) val path = morph.toPath(progress = percentage).asComposePath() path.transform(matrix) return Outline.Generic(path) } } @Preview @Composable private fun RotatingScallopedProfilePic() { val shapeA = remember { RoundedPolygon( 12, rounding = CornerRounding(0.2f) ) } val shapeB = remember { RoundedPolygon.star( 12, rounding = CornerRounding(0.2f) ) } val morph = remember { Morph(shapeA, shapeB) } val infiniteTransition = rememberInfiniteTransition("infinite outline movement") val animatedProgress = infiniteTransition.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( tween(2000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "animatedMorphProgress" ) val animatedRotation = infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( tween(6000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "animatedMorphProgress" ) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Image( painter = painterResource(id = R.drawable.dog), contentDescription = "Dog", contentScale = ContentScale.Crop, modifier = Modifier .clip( CustomRotatingMorphShape( morph, animatedProgress.value, animatedRotation.value ) ) .size(200.dp) ) } }
Этот код дает следующий забавный результат:

Пользовательские полигоны
Если формы, созданные из правильных многоугольников, не подходят для вашего варианта использования, вы можете создать более индивидуальную форму со списком вершин. Например, вы можете создать форму сердца, как здесь:

Вы можете указать отдельные вершины этой фигуры, используя перегрузку RoundedPolygon
, которая принимает массив с плавающей точкой из координат x, y.
Чтобы разбить многоугольник сердца, обратите внимание, что полярная система координат для указания точек упрощает задачу, чем использование декартовой системы координат (x, y), где 0°
начинается с правой стороны и продолжается по часовой стрелке, а 270°
находится в положении 12 часов:

Теперь форму можно определить проще, указав угол (𝜭) и радиус от центра в каждой точке:

Теперь вершины можно создать и передать в функцию RoundedPolygon
:
val vertices = remember { val radius = 1f val radiusSides = 0.8f val innerRadius = .1f floatArrayOf( radialToCartesian(radiusSides, 0f.toRadians()).x, radialToCartesian(radiusSides, 0f.toRadians()).y, radialToCartesian(radius, 90f.toRadians()).x, radialToCartesian(radius, 90f.toRadians()).y, radialToCartesian(radiusSides, 180f.toRadians()).x, radialToCartesian(radiusSides, 180f.toRadians()).y, radialToCartesian(radius, 250f.toRadians()).x, radialToCartesian(radius, 250f.toRadians()).y, radialToCartesian(innerRadius, 270f.toRadians()).x, radialToCartesian(innerRadius, 270f.toRadians()).y, radialToCartesian(radius, 290f.toRadians()).x, radialToCartesian(radius, 290f.toRadians()).y, ) }
Вершины необходимо перевести в декартовы координаты с помощью функции radialToCartesian
:
internal fun Float.toRadians() = this * PI.toFloat() / 180f internal val PointZero = PointF(0f, 0f) internal fun radialToCartesian( radius: Float, angleRadians: Float, center: PointF = PointZero ) = directionVectorPointF(angleRadians) * radius + center internal fun directionVectorPointF(angleRadians: Float) = PointF(cos(angleRadians), sin(angleRadians))
Предыдущий код дает вам необработанные вершины для сердца, но вам нужно скруглить определенные углы, чтобы получить выбранную форму сердца. Углы в 90°
и 270°
не имеют скругления, но другие углы имеют. Чтобы добиться пользовательского скругления для отдельных углов, используйте параметр perVertexRounding
:
val rounding = remember { val roundingNormal = 0.6f val roundingNone = 0f listOf( CornerRounding(roundingNormal), CornerRounding(roundingNone), CornerRounding(roundingNormal), CornerRounding(roundingNormal), CornerRounding(roundingNone), CornerRounding(roundingNormal), ) } val polygon = remember(vertices, rounding) { RoundedPolygon( vertices = vertices, perVertexRounding = rounding ) } Box( modifier = Modifier .drawWithCache { val roundedPolygonPath = polygon.toPath().asComposePath() onDrawBehind { scale(size.width * 0.5f, size.width * 0.5f) { translate(size.width * 0.5f, size.height * 0.5f) { drawPath(roundedPolygonPath, color = Color(0xFFF15087)) } } } } .size(400.dp) )
В результате получается розовое сердце:

Если предыдущие фигуры не подходят для вашего варианта использования, рассмотрите возможность использования класса Path
для рисования пользовательской фигуры или загрузки файла ImageVector
с диска. Библиотека graphics-shapes
не предназначена для использования с произвольными фигурами, а специально предназначена для упрощения создания скругленных многоугольников и анимаций морфинга между ними.
Дополнительные ресурсы
Дополнительную информацию и примеры можно найти на следующих ресурсах: