С помощью 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 определяет размер закругления углов.Сглаживание
Коэффициент сглаживания определяет, сколько времени требуется, чтобы пройти путь от закругленной части угла до края. Коэффициент сглаживания, равный 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() )

Используйте многоугольник в качестве отсечения
В Compose часто используется модификатор clip для изменения способа рендеринга компонуемого объекта и для использования теней, которые рисуются вокруг области отсечения:
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 не предназначена для использования с произвольными фигурами, а специально разработана для упрощения создания скругленных многоугольников и анимации перехода между ними.
Дополнительные ресурсы
Для получения дополнительной информации и примеров см. следующие ресурсы: