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

Сглаживание

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

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

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

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

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

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

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