Compose의 도형

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

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

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

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

이 라이브러리를 사용하면 다각형으로 만든 도형을 만들 수 있습니다. 다각형 도형에는 직선 가장자리와 날카로운 모서리만 있지만 이러한 도형은 원하는 경우 둥근 모서리를 사용할 수 있습니다. 이렇게 하면 서로 다른 두 도형 간에 손쉽게 모핑할 수 있습니다. 모핑은 임의의 셰이프 사이에서 어렵고 디자인 시간 문제가 되는 경향이 있습니다. 하지만 이 라이브러리를 사용하면 유사한 다각형 구조로 이러한 도형 간에 모핑을 수행할 수 있어 작업이 간단해집니다.

다각형 만들기

다음 스니펫은 그리기 영역의 중앙에 점 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까지)를 사용하면 모서리가 별도의 곡선 3개로 둥글게 됩니다.

스무딩 계수가 0 (평활화되지 않음)이면 이전 예와 같이 모서리 주위에 지정된 반올림 반경이 있는 원을 따르는 단일 입방 곡선을 생성합니다.
그림 5. 평활화 인수가 0 (평활화되지 않음)이면 이전 예와 같이 지정된 반올림 반경을 가진 모서리 주위의 원을 따르는 단일 입방 곡선을 생성합니다.
0이 아닌 평활화 계수는 꼭짓점을 둥글게 하는 세 개의 입방 곡선을 생성합니다. 하나는 안쪽 원형 곡선 (이전과 같음)과 안쪽 곡선과 다각형 가장자리 사이를 전환하는 두 개의 측면 곡선입니다.
그림 6. 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)
)

평활화 매개변수의 차이를 보여주는 검은색 삼각형 2개
그림 7. 스무딩 매개변수의 차이를 보여주는 검은색 삼각형 2개

크기 및 위치

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

모핑 도형

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

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

둥근 삼각형과 사각형 사이의 50%
그림 8. 직각삼각형과 정사각형이 50% 나 됩니다.

대부분의 경우 모핑은 정적 렌더링뿐만 아니라 애니메이션의 일부로 실행됩니다. 두 항목 간에 애니메이션을 적용하려면 표준 Compose의 Animation 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)를 사용하는 것보다 쉽게 확인할 수 있습니다. 여기서 는 오른쪽에서 시작하여 시계 방향으로 진행되며 12시 정각 위치에서 270°을 사용합니다.

하트 모양
그림 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 라이브러리는 임의의 도형을 위한 것이 아니며, 특히 둥근 다각형 및 그 사이에 모핑 애니메이션을 간단하게 만들기 위한 것입니다.

추가 리소스

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