Фигуры в Compose

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

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

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

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

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

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

Шестиугольник с текстом «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. Пользовательская форма, примененная в качестве клипсы.

Кнопка «Morph» при нажатии

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

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

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