Compose의 도형

Compose를 사용하면 다각형으로 만든 도형을 만들 수 있습니다. 예를 들어 다음과 같은 도형을 만들 수 있습니다.

그리기 영역 중앙에 있는 파란색 육각형
그림 1. graphics-shapes 라이브러리로 만들 수 있는 다양한 도형의 예

Compose에서 원형으로 잘린 맞춤 다각형을 만들려면 app/build.gradlegraphics-shapes 종속 항목을 추가합니다.

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 매개변수를 사용합니다. 이 메서드는 두 매개변수 radiussmoothing를 사용합니다. 각 둥근 모서리는 1~3개의 입방 곡선으로 구성되며, 이 중 중앙은 원호 모양을 가지며, 두 개의 측면 ('측면') 곡선은 도형의 가장자리에서 중앙 곡선으로 전환됩니다.

반경

radius는 정점을 둥글게 처리하는 데 사용되는 원의 반지름입니다.

예를 들어 다음과 같이 모서리가 둥근 삼각형을 만들 수 있습니다.

모서리가 둥근 삼각형
그림 3. 모서리가 둥근 삼각형
둥근 모서리의 원형 둥근 크기는 둥근 반경 r로 결정됩니다.
그림 4. 둥근 모서리의 원형 둥근 크기는 둥근 반경 r로 결정됩니다.

부드러움

부드러움은 모서리의 원형 둥근 부분에서 가장자리까지 이동하는 데 걸리는 시간을 결정하는 요소입니다. 평활화 계수가 0이면(평활화되지 않음, CornerRounding의 기본값) 완전히 원형의 모서리 라운딩이 됩니다. 0이 아닌 부드럽게 처리 계수 (최대 1.0)를 사용하면 모서리가 세 개의 별도 곡선으로 둥글게 처리됩니다.

평활화 계수가 0 (평활화되지 않음)인 경우 앞의 예와 같이 지정된 반올림 반경으로 모서리 주위의 원을 따라가는 단일 3차 곡선이 생성됩니다.
그림 5. 평활화 계수가 0 (평활화되지 않음)인 경우 앞의 예와 같이 지정된 라운딩 반지름으로 모서리 주위의 원을 따라가는 단일 3차 곡선이 생성됩니다.
0이 아닌 부드럽게 처리 계수는 꼭짓점을 둥글게 처리하는 세 개의 3차 곡선을 생성합니다. 이전과 같이 내부 원형 곡선과 내부 곡선과 다각형 가장자리 간에 전환하는 두 개의 측면 곡선입니다.
그림 6. 0이 아닌 부드럽게 처리 계수는 꼭짓점을 둥글게 처리하는 세 개의 3차 곡선을 생성합니다. 이전과 같이 내부 원형 곡선과 내부 곡선과 다각형 가장자리 간에 전환하는 두 개의 측면 곡선입니다.

예를 들어 아래 스니펫은 스무딩을 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. 두 개의 검은색 삼각형은 보간 매개변수의 차이를 보여줍니다.

크기 및 위치

기본적으로 도형은 중심 (0, 0) 주위에 반경이 1인 도형으로 만들어집니다. 이 반경은 도형의 기반이 되는 다각형의 중심과 외부 꼭짓점 사이의 거리를 나타냅니다. 모서리를 둥글게 처리하면 모서리가 둥글게 처리되는 정점보다 중심에 더 가까워지므로 도형이 더 작아집니다. 다각형의 크기를 조정하려면 radius 값을 조정합니다. 위치를 조정하려면 다각형의 centerX 또는 centerY를 변경합니다. 또는 DrawScope#translate()와 같은 표준 DrawScope 변환 함수를 사용하여 객체를 변환하여 크기, 위치, 회전을 변경할 수 있습니다.

모양 모핑

Morph 객체는 두 다각형 도형 간의 애니메이션을 나타내는 새로운 도형입니다. 두 도형 간에 모핑하려면 두 개의 RoundedPolygons와 이 두 도형을 사용하는 Morph 객체를 만듭니다. 시작 도형과 끝 도형 사이의 도형을 계산하려면 0과 1 사이의 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()
)

위 예에서 진행률은 두 도형(둥근 삼각형과 정사각형)의 정확히 중간 지점이므로 다음과 같은 결과가 나옵니다.

둥근 삼각형과 정사각형의 중간 지점
그림 8. 둥근 삼각형과 정사각형의 중간 지점 50%

대부분의 시나리오에서 모핑은 정적 렌더링이 아닌 애니메이션의 일부로 실행됩니다. 이 두 가지 간에 애니메이션을 적용하려면 표준 Compose의 애니메이션 API를 사용하여 시간 경과에 따라 진행률 값을 변경하면 됩니다. 예를 들어 다음과 같이 두 도형 간의 모핑을 무한으로 애니메이션할 수 있습니다.

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 라이브러리를 사용하여 누르면 두 도형 간에 모핑되는 버튼을 만들 수 있습니다. 먼저 Shape를 확장하는 MorphPolygonShape를 만들고 적절하게 맞게 크기를 조절하고 변환합니다. 도형에 애니메이션을 적용할 수 있도록 진행률을 전달합니다.

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

이 모프 도형을 사용하려면 두 개의 다각형 shapeAshapeB을 만듭니다. 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 하트 모양

x, y 좌표의 부동 소수점 배열을 사용하는 RoundedPolygon 오버로드를 사용하여 이 도형의 개별 정점을 지정할 수 있습니다.

하트 다각형을 분해하려면 점을 지정하는 극좌표계를 사용하면 데카르트 (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 라이브러리는 임의의 도형에 사용하는 것이 아니라 특히 둥근 다각형을 간편하게 만들고 그 사이의 모프 애니메이션을 만드는 것을 목적으로 합니다.

추가 리소스

자세한 내용과 예시는 다음 리소스를 참고하세요.