Compose의 도형

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

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

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

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 매개변수를 사용합니다. 이 함수는 radiussmoothing의 두 매개변수를 사용합니다. 각 둥근 모서리는 1~3개의 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. 모서리가 둥근 삼각형과 정사각형의 중간 지점입니다.

대부분의 시나리오에서 모핑은 정적 렌더링이 아닌 애니메이션의 일부로 실행됩니다. 이 두 가지 사이를 애니메이션으로 처리하려면 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 라이브러리는 임의의 모양에 사용하기 위한 것이 아니라 둥근 다각형과 그 사이의 모핑 애니메이션 생성을 간소화하기 위한 것입니다.

추가 리소스

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