С помощью Compose вы можете создавать фигуры, состоящие из многоугольников. Например, вы можете сделать следующие виды фигур:
Чтобы создать собственный закругленный многоугольник в 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() )
В этом примере библиотека создает RoundedPolygon
, который содержит геометрию, представляющую запрошенную форму. Чтобы нарисовать эту фигуру в приложении Compose, вы должны получить из него объект Path
, чтобы преобразовать фигуру в форму, которую Compose умеет рисовать.
Закругление углов многоугольника
Чтобы скруглить углы многоугольника, используйте параметр CornerRounding
. Это принимает два параметра: radius
и smoothing
. Каждый закругленный угол состоит из 1-3 кубических кривых, центр которых имеет форму дуги окружности, а две боковые («фланкирующие») кривые переходят от края формы к центральной кривой.
Радиус
radius
— это радиус круга, используемого для округления вершины.
Например, следующий треугольник с закругленными углами делается следующим образом:
Сглаживание
Сглаживание — это фактор, определяющий, сколько времени потребуется, чтобы перейти от круглой закругленной части угла к краю. Коэффициент сглаживания 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) фигурами:
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() )
Использовать многоугольник в качестве клипа
Модификатор 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) ) }
Это приводит к следующему:
Возможно, это не сильно отличается от того, что отображалось раньше, но позволяет использовать другие функции 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
не предназначена для использования с произвольными фигурами, а специально предназначена для упрощения создания закругленных многоугольников и анимации морфинга между ними.
Дополнительные ресурсы
Дополнительную информацию и примеры можно найти на следующих ресурсах: