Фигуры в Compose

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

Синий шестиугольник в центре области рисования
Рисунок 1. Примеры различных фигур, которые можно создать с помощью библиотеки graphics-shapes

Чтобы создать пользовательский скругленный многоугольник в 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()
)

Синий шестиугольник в центре области рисования
Рисунок 2. Синий шестиугольник в центре области рисования.

В этом примере библиотека создает RoundedPolygon , который содержит геометрию, представляющую запрошенную форму. Чтобы нарисовать эту форму в приложении Compose, необходимо получить из нее объект Path , чтобы преобразовать форму в форму, которую Compose умеет рисовать.

Закруглить углы многоугольника

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

Радиус

radius — это радиус окружности, используемой для округления вершины.

Например, следующий треугольник с закругленными углами создается следующим образом:

Треугольник с закругленными углами
Рисунок 3. Треугольник с закругленными углами.
Радиус скругления r определяет размер кругового скругления закругленных углов
Рисунок 4. Радиус скругления r определяет размер кругового скругления скругленных углов.

Сглаживание

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

Коэффициент сглаживания 0 (несглаженный) создает одну кубическую кривую, которая следует по окружности вокруг угла с указанным радиусом скругления, как в предыдущем примере
Рисунок 5. Коэффициент сглаживания 0 (несглаженный) создает одну кубическую кривую, которая следует по окружности вокруг угла с указанным радиусом скругления, как в предыдущем примере.
Ненулевой коэффициент сглаживания создает три кубические кривые для скругления вершины: внутреннюю круговую кривую (как и прежде) плюс две боковые кривые, которые переходят между внутренней кривой и краями многоугольника.
Рисунок 6. Ненулевой коэффициент сглаживания создает три кубические кривые для округления вершины: внутреннюю круговую кривую (как и прежде) плюс две боковые кривые, которые переходят между внутренней кривой и краями многоугольника.

Например, приведенный ниже фрагмент кода иллюстрирует тонкую разницу между установкой сглаживания на 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)
)

Два черных треугольника, показывающие разницу в параметрах сглаживания.
Рисунок 7. Два черных треугольника, показывающие разницу в параметре сглаживания.

Размер и положение

По умолчанию фигура создается с радиусом 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()
)

В приведенном выше примере прогресс находится точно посередине между двумя фигурами (скругленный треугольник и квадрат), что дает следующий результат:

50% пути между скругленным треугольником и квадратом
Рисунок 8. 50% пути между скругленным треугольником и квадратом.

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

Бесконечное превращение квадрата в скругленный треугольник
Рисунок 9. Бесконечная трансформация между квадратом и скругленным треугольником.

Использовать многоугольник как клип

Модификатор 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)
    )
}

Это приводит к следующему:

Шестиугольник с текстом «привет, напиши» в центре.
Рисунок 10. Шестиугольник с текстом «Hello Compose» в центре.

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

    )
}

Собака в шестиугольнике с тенью по краям
Рисунок 11. Пользовательская форма, примененная в качестве клипсы.

Кнопка морфинга при нажатии

Вы можете использовать библиотеку 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))
}

Это приводит к следующей анимации при нажатии на поле:

Морф применяется как щелчок между двумя фигурами
Рисунок 12. Морф применяется как щелчок между двумя фигурами.

Анимированная форма, изменяющаяся бесконечно

Для бесконечной анимации морф-формы используйте 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)
        )
    }
}

Этот код дает следующий забавный результат:

Форма сердца
Рисунок 13. Профильное изображение, обрезанное вращающейся зубчатой ​​фигурой.

Пользовательские полигоны

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

Форма сердца
Рисунок 14. Форма сердца.

Вы можете указать отдельные вершины этой фигуры, используя перегрузку RoundedPolygon , которая принимает массив с плавающей точкой из координат x, y.

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

Форма сердца
Рисунок 15. Форма сердца с координатами.

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

Форма сердца
Рисунок 16. Форма сердца с координатами, без округления.

Теперь вершины можно создать и передать в функцию 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)
)

В результате получается розовое сердце:

Форма сердца
Рисунок 17. Результат в форме сердца.

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

Дополнительные ресурсы

Дополнительную информацию и примеры можно найти на следующих ресурсах:

,

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

Синий шестиугольник в центре области рисования
Рисунок 1. Примеры различных фигур, которые можно создать с помощью библиотеки graphics-shapes

Чтобы создать пользовательский скругленный многоугольник в 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()
)

Синий шестиугольник в центре области рисования
Рисунок 2. Синий шестиугольник в центре области рисования.

В этом примере библиотека создает RoundedPolygon , который содержит геометрию, представляющую запрошенную форму. Чтобы нарисовать эту форму в приложении Compose, необходимо получить из нее объект Path , чтобы преобразовать форму в форму, которую Compose умеет рисовать.

Закруглить углы многоугольника

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

Радиус

radius — это радиус окружности, используемой для округления вершины.

Например, следующий треугольник с закругленными углами создается следующим образом:

Треугольник с закругленными углами
Рисунок 3. Треугольник с закругленными углами.
Радиус скругления r определяет размер кругового скругления закругленных углов
Рисунок 4. Радиус скругления r определяет размер кругового скругления скругленных углов.

Сглаживание

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

Коэффициент сглаживания 0 (несглаженный) создает одну кубическую кривую, которая следует по окружности вокруг угла с указанным радиусом скругления, как в предыдущем примере
Рисунок 5. Коэффициент сглаживания 0 (несглаженный) создает одну кубическую кривую, которая следует по окружности вокруг угла с указанным радиусом скругления, как в предыдущем примере.
Ненулевой коэффициент сглаживания создает три кубические кривые для скругления вершины: внутреннюю круговую кривую (как и прежде) плюс две боковые кривые, которые переходят между внутренней кривой и краями многоугольника.
Рисунок 6. Ненулевой коэффициент сглаживания создает три кубические кривые для округления вершины: внутреннюю круговую кривую (как и прежде) плюс две боковые кривые, которые переходят между внутренней кривой и краями многоугольника.

Например, приведенный ниже фрагмент кода иллюстрирует тонкую разницу между установкой сглаживания на 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)
)

Два черных треугольника, показывающие разницу в параметрах сглаживания.
Рисунок 7. Два черных треугольника, показывающие разницу в параметре сглаживания.

Размер и положение

По умолчанию фигура создается с радиусом 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()
)

В приведенном выше примере прогресс находится точно посередине между двумя фигурами (скругленный треугольник и квадрат), что дает следующий результат:

50% пути между скругленным треугольником и квадратом
Рисунок 8. 50% пути между скругленным треугольником и квадратом.

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

Бесконечное превращение квадрата в скругленный треугольник
Рисунок 9. Бесконечная трансформация между квадратом и скругленным треугольником.

Использовать многоугольник как клип

Модификатор 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)
    )
}

Это приводит к следующему:

Шестиугольник с текстом «привет, напиши» в центре.
Рисунок 10. Шестиугольник с текстом «Hello Compose» в центре.

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

    )
}

Собака в шестиугольнике с тенью по краям
Рисунок 11. Пользовательская форма, примененная в качестве клипсы.

Кнопка морфинга при нажатии

Вы можете использовать библиотеку 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))
}

Это приводит к следующей анимации при нажатии на поле:

Морф применяется как щелчок между двумя фигурами
Рисунок 12. Морф применяется как щелчок между двумя фигурами.

Анимированная форма, изменяющаяся бесконечно

Для бесконечной анимации морф-формы используйте 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)
        )
    }
}

Этот код дает следующий забавный результат:

Форма сердца
Рисунок 13. Профильное изображение, обрезанное вращающейся зубчатой ​​фигурой.

Пользовательские полигоны

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

Форма сердца
Рисунок 14. Форма сердца.

Вы можете указать отдельные вершины этой фигуры, используя перегрузку RoundedPolygon , которая принимает массив с плавающей точкой из координат x, y.

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

Форма сердца
Рисунок 15. Форма сердца с координатами.

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

Форма сердца
Рисунок 16. Форма сердца с координатами, без округления.

Теперь вершины можно создать и передать в функцию 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)
)

В результате получается розовое сердце:

Форма сердца
Рисунок 17. Результат в форме сердца.

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

Дополнительные ресурсы

Дополнительную информацию и примеры можно найти на следующих ресурсах:

,

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

Синий шестиугольник в центре области рисования
Рисунок 1. Примеры различных фигур, которые можно создать с помощью библиотеки graphics-shapes

Чтобы создать пользовательский скругленный многоугольник в 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()
)

Синий шестиугольник в центре области рисования
Рисунок 2. Синий шестиугольник в центре области рисования.

В этом примере библиотека создает RoundedPolygon , который содержит геометрию, представляющую запрошенную форму. Чтобы нарисовать эту форму в приложении Compose, необходимо получить из нее объект Path , чтобы преобразовать форму в форму, которую Compose умеет рисовать.

Закруглить углы многоугольника

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

Радиус

radius — это радиус окружности, используемой для округления вершины.

Например, следующий треугольник с закругленными углами создается следующим образом:

Треугольник с закругленными углами
Рисунок 3. Треугольник с закругленными углами.
Радиус скругления r определяет размер кругового скругления закругленных углов
Рисунок 4. Радиус скругления r определяет размер кругового скругления скругленных углов.

Сглаживание

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

Коэффициент сглаживания 0 (несглаженный) создает одну кубическую кривую, которая следует по окружности вокруг угла с указанным радиусом скругления, как в предыдущем примере
Рисунок 5. Коэффициент сглаживания 0 (несглаженный) создает одну кубическую кривую, которая следует по окружности вокруг угла с указанным радиусом скругления, как в предыдущем примере.
Ненулевой коэффициент сглаживания создает три кубические кривые для скругления вершины: внутреннюю круговую кривую (как и прежде) плюс две боковые кривые, которые переходят между внутренней кривой и краями многоугольника.
Рисунок 6. Ненулевой коэффициент сглаживания создает три кубические кривые для округления вершины: внутреннюю круговую кривую (как и прежде) плюс две боковые кривые, которые переходят между внутренней кривой и краями многоугольника.

Например, приведенный ниже фрагмент кода иллюстрирует тонкую разницу между установкой сглаживания на 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)
)

Два черных треугольника, показывающие разницу в параметрах сглаживания.
Рисунок 7. Два черных треугольника, показывающие разницу в параметре сглаживания.

Размер и положение

По умолчанию фигура создается с радиусом 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()
)

В приведенном выше примере прогресс находится точно посередине между двумя фигурами (скругленный треугольник и квадрат), что дает следующий результат:

50% пути между скругленным треугольником и квадратом
Рисунок 8. 50% пути между скругленным треугольником и квадратом.

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

Бесконечное превращение квадрата в скругленный треугольник
Рисунок 9. Бесконечная трансформация между квадратом и скругленным треугольником.

Использовать многоугольник как клип

Модификатор 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)
    )
}

Это приводит к следующему:

Шестиугольник с текстом «привет, напиши» в центре.
Рисунок 10. Шестиугольник с текстом «Hello Compose» в центре.

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

    )
}

Собака в шестиугольнике с тенью по краям
Рисунок 11. Пользовательская форма, примененная в качестве клипсы.

Кнопка морфинга при нажатии

Вы можете использовать библиотеку 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))
}

Это приводит к следующей анимации при нажатии на поле:

Морф применяется как щелчок между двумя фигурами
Рисунок 12. Морф применяется как щелчок между двумя фигурами.

Анимированная форма, изменяющаяся бесконечно

Для бесконечной анимации морф-формы используйте 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)
        )
    }
}

Этот код дает следующий забавный результат:

Форма сердца
Рисунок 13. Профильное изображение, обрезанное вращающейся зубчатой ​​фигурой.

Пользовательские полигоны

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

Форма сердца
Рисунок 14. Форма сердца.

Вы можете указать отдельные вершины этой фигуры, используя перегрузку RoundedPolygon , которая принимает массив с плавающей точкой из координат x, y.

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

Форма сердца
Рисунок 15. Форма сердца с координатами.

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

Форма сердца
Рисунок 16. Форма сердца с координатами, без округления.

Теперь вершины можно создать и передать в функцию 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)
)

В результате получается розовое сердце:

Форма сердца
Рисунок 17. Результат в форме сердца.

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

Дополнительные ресурсы

Дополнительную информацию и примеры можно найти на следующих ресурсах: