Фигуры в Compose

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

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

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

implementation "androidx.graphics:graphics-shapes:1.0.0-rc01"

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

Создание полигонов

Следующий фрагмент создает базовую многоугольную форму с 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)
    )
}

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

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

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

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