С помощью Compose можно создавать фигуры, состоящие из многоугольников. Например, вы можете сделать следующие виды фигур:
Чтобы создать собственный скругленный многоугольник в 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() )
В этом примере библиотека создает объект RoundedPolygon, который содержит геометрию, представляющую запрошенную фигуру. Чтобы нарисовать эту фигуру в приложении Compose, вам нужно получить из нее объект Path, чтобы преобразовать фигуру в форму, которую Compose умеет рисовать.
Как скруглить углы многоугольника
Чтобы скруглить углы многоугольника, используйте параметр CornerRounding. Он принимает два параметра: radius и smoothing. Каждый скругленный угол состоит из 1–3 кубических кривых, центр которых имеет форму дуги окружности, а две боковые ("фланговые") кривые переходят от края фигуры к центральной кривой.
Радиус
radius – радиус круга, используемого для скругления вершины.
Например, треугольник со скругленными углами создается следующим образом:
r определяет размер скругления углов.Выпрямляющие
Сглаживание – это коэффициент, который определяет, сколько времени требуется, чтобы перейти от круглой закругленной части угла к краю. Коэффициент сглаживания 0 (без сглаживания, значение по умолчанию для CornerRounding) приводит к чисто круглому скруглению углов. Ненулевой коэффициент сглаживания (максимум 1,0) приводит к тому, что угол скругляется тремя отдельными кривыми.
Например, во фрагменте ниже показана разница между сглаживанием 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) )
Размер и положение
По умолчанию фигура создается с радиусом 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() )
В примере выше прогресс находится ровно посередине между двумя фигурами (закругленным треугольником и квадратом), что дает следующий результат:
В большинстве случаев морфинг выполняется в рамках анимации, а не просто статического рендеринга. Чтобы анимировать переход между ними, можно использовать стандартные 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() )
Использовать многоугольник в качестве области обрезки
В 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) ) }
В результате получится следующее:
Это может выглядеть не так уж и по-другому, но это позволяет использовать другие функции в 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) ) }
Изменение кнопки при нажатии
Вы можете использовать библиотеку 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)) }
В результате при нажатии на поле будет воспроизводиться следующая анимация:
Анимация: фигура бесконечно меняет форму
Чтобы зациклить анимацию морфинга, используйте 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) ) } }
Этот код дает следующий забавный результат:
Специальные многоугольники
Если вам не подходят фигуры, созданные на основе правильных многоугольников, вы можете создать более сложную фигуру, указав список вершин. Например, вы можете создать форму сердца, как здесь:
Вы можете указать отдельные вершины этой фигуры, используя перегрузку RoundedPolygon, которая принимает массив с плавающей запятой координат x, y.
Чтобы разбить многоугольник в форме сердца, обратите внимание,что полярная система координат для указания точек упрощает эту задачу по сравнению с декартовой системой координат (x, y), где 0° начинается с правой стороны и продолжается по часовой стрелке, а 270° находится в положении "12 часов":
Теперь форму можно задать проще, указав угол (𝜭) и радиус от центра в каждой точке:
Теперь можно создать вершины и передать их функции 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) )
В результате получится розовое сердце:
Если ни одна из этих фигур вам не подходит, вы можете нарисовать собственную с помощью класса Path или загрузить файл ImageVector с диска. Библиотека graphics-shapes не предназначена для работы с произвольными фигурами. Она создана специально для того, чтобы упростить создание скругленных многоугольников и анимаций морфинга между ними.
Дополнительные ресурсы
Дополнительную информацию и примеры можно найти в следующих ресурсах:
- Блог: Shape of Things to Come – Shapes
- Блог: преобразование фигур в Android
- Демонстрация Shapes на GitHub