Фигуры в Compose

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

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

Чтобы создать собственный скругленный многоугольник в Compose, добавьте зависимость graphics-shapes в файл app/build.gradle:

implementation "androidx.graphics:graphics-shapes:1.0.1"

Эта библиотека позволяет создавать фигуры из многоугольников. В отличие от многоугольников, у которых есть только прямые края и острые углы, у этих фигур могут быть закругленные углы. Она позволяет легко трансформировать одну фигуру в другую. Морфинг между произвольными фигурами сложен и, как правило, является проблемой на этапе проектирования. Но эта библиотека упрощает задачу, преобразуя эти формы с похожими полигональными структурами.

Как создавать многоугольники

В следующем фрагменте кода создается простой многоугольник с шестью точками в центре области рисования:

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 определяет размер скругления углов.

Выпрямляющие

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

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

В Compose часто используется модификатор clip, который позволяет изменить способ отрисовки composable-функции и добавить тени вокруг области обрезки.

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)
    )
}

В результате получится следующее:

Шестиугольник с текстом "hello compose" в центре.
Рисунок 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), где 0° начинается с правой стороны и продолжается по часовой стрелке, а 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 не предназначена для работы с произвольными фигурами. Она создана специально для того, чтобы упростить создание скругленных многоугольников и анимаций морфинга между ними.

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

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