[Jetpack Compose] Color Picker 만들기

2025. 2. 20. 15:52·Jetpack Compose

Color Picker

 

 

시작하며

앱 기본 색상 설정화면과 아이템 항목별 배경색 설정을 위해 컬러 피커가 필요해져 몇 개의 라이브러리를 찾아보았는데 기능적으로는 괜찮았지만 디자인 커스텀을 하기 어려워 직접 만들어보았다. 디자인이 마음에 든다면 그대로 사용해도 좋고 필자처럼 본인이 직접 만들고자 하는 사람은 동작 원리를 참고해 직접 커스텀하면 된다.

 

 

사용 화면

- 첫 번째 영상을 보게 되면 원형 팔레트에서 색상과 채도를 함께 설정하고 그 아래에 있는 두 개의 슬라이더를 통해 각각 명도와 투명도를 조절하게 된다.

 

- 두 번째 영상을 보게 되면 상단의 ARGB값을 수정해 색상을 직접 입력할 수도 있는데 먼저 ffaaggnn는 존재하지 않는 컬러라서 없다는 표시가 뜨게 된다. 다음으로 ffabab는 ARGB 형식(8자리)이 아닌 RGB 형식(6자리)으로 인식한 것이며, 마지막으로 ffababab는 ARGB 형식(8자리)으로 인식한 결과이다.

 

 

코드 설명

상단의 피커를 따로 제작해 사용할 곳에서 불러와 하단의 색상 목록과 함께 사용하는 방식으로 제작하였다. 필자의 컬러 클래스는 androidx.compose.ui.graphics의 Color로 즉, 컴포즈의 컬러 클래스이며 기존 android의 Color를 사용할 것이라면 함수를 수정해서 사용하면 된다.

 

- 함수

fun hsvToColor(h: Float, s: Float, v: Float, alpha: Float = 1f): Color {
    val androidColor = android.graphics.Color.HSVToColor((alpha * 255).toInt(), floatArrayOf(h, s, v))
    return Color(androidColor)
}

• h, s, v는 색상(hue), 채도(saturation), 명도(value)를 뜻하며 alpha는 투명도이다. 안드로이드 컬러에 있는 HSVToColor()를 사용해 컴포즈 컬러로 변환해 준다.

 

• HSVToColor()에서 사용하는 alpha는 0에서 255 사이의 Int 타입이지만 슬라이더에서 Float 타입을 사용하기 때문에 255를 곱해 Int 타입으로 변환해 준다.

 

fun Color.toHsv(): Triple<Float, Float, Float> {
    val hsv = FloatArray(3)
    android.graphics.Color.colorToHSV(this.toArgb(), hsv)
    return Triple(hsv[0], hsv[1], hsv[2])
}

fun Color.toHexString(): String {
    return String.format("#%08X", this.toArgb())
}

• 컬러의 확장 함수로 제작한 toHSV()는 초기 컬러값을 사용하지 않을 것이라면 필요하지 않다.

 

• toHexString()은 피커 상단의 ARGB을 표현하기 위해 String 타입으로 변환하는 함수이다.

 

fun String.toColorFromHexString(): Color {
	return runCatching { Color(android.graphics.Color.parseColor(this)) }
		.getOrDefault(Color.Unspecified)
}

직접 입력한 값을 컬러로 변환하는데 parseColor()에서 오류가 발생한다면 앱이 크래시 되므로 runCatching을 사용해 크래시를 방지해 주고 Color.Unspecified로 설정해 준다.

 

- Composable

@Composable
fun ColorPicker(
    initialColor: Color,
    onAddColor: (Color) -> Unit
) {
    val focusManager = LocalFocusManager.current
    var hue by remember { mutableFloatStateOf(initialColor.toHsv().first) } // 색상
    var saturation by remember { mutableFloatStateOf(initialColor.toHsv().second) } // 채도
    val brightness = remember { SliderState(initialColor.toHsv().third) } // 명도(Value)
    val alpha = remember { SliderState(1f) } // 투명도
    val selectedColor by remember { derivedStateOf { hsvToColor(hue, saturation, brightness.value, alpha.value) } }
    val colorText = rememberTextFieldState()
    
    LaunchedEffect(colorText) {
        launch { // 선택 색상에 따라 텍스트 변경
            snapshotFlow { selectedColor.toHexString() }.distinctUntilChanged().collect { text ->
                if (text != colorText.text) { colorText.setTextAndPlaceCursorAtEnd(text) }
            }
        }
        launch { // 첫 글자 #으로 고정
            snapshotFlow { colorText.text }.distinctUntilChanged().collect { text ->
                if (!text.startsWith("#")) { colorText.setTextAndPlaceCursorAtEnd("#$text") }
            }
        }
    }
    
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .fillMaxWidth()
            .background(Color.White)
            .noRippleClickable { focusManager.clearFocus() }
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 12.dp)
        ) {
            colorText.text.toString().toColorFromHexString().let {
                if (it == Color.Unspecified) { // 존재하지 않는 색상 표시
                    Box(
                        modifier = Modifier
                            .size(50.dp)
                            .border(1.5.dp, Color.Red, CircleShape)
                            .drawBehind {
                                val radius = size.minDimension / 2
                                val center = Offset(size.width / 2, size.height / 2)

                                drawLine(
                                    color = Color.Red,
                                    start = Offset(center.x + radius * 0.7f, center.y - radius * 0.7f),
                                    end = Offset(center.x - radius * 0.7f, center.y + radius * 0.7f),
                                    strokeWidth = 1.5.dp.toPx()
                                )
                            }
                    )
                } else {
                    Box(
                        modifier = Modifier
                            .size(50.dp)
                            .drawBehind { drawCircle(it) }
                    )
                }
            }
            BasicTextField(
                state = colorText,
                textStyle = LocalTextStyle.current.copy(
                    fontSize = 18.sp,
                    fontWeight = FontWeight.Medium
                ),
                lineLimits = TextFieldLineLimits.SingleLine,
                decorator = { innerTextField ->
                    Box(
                        content = { innerTextField() },
                        contentAlignment = Alignment.Center,
                        modifier = Modifier
                            .fillMaxWidth(0.5f)
                            .height(50.dp)
                            .border(1.dp, Color(0xFFD0D0D0), CircleShape)
                            .padding(horizontal = 20.dp)
                    )
                }
            )
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier
                    .size(50.dp)
                    .border(1.dp, Color(0xFFD0D0D0), CircleShape)
                    .noRippleClickable {
                        colorText.text.toString().toColorFromHexString().let {
                            if (it != Color.Unspecified) { onAddColor(it) }
                        }
                    }
            ) {
                Icon(
                    imageVector = ImageVector.vectorResource(R.drawable.ic_add_thin),
                    contentDescription = "색상 추가",
                    tint = Color(0xFFB0B0B0),
                    modifier = Modifier
                        .size(28.dp)
                )
            }
        }
        Box(
            modifier = Modifier
                .fillMaxWidth(0.8f)
                .aspectRatio(1f)
                .pointerInput(Unit) {
                    detectDragGestures { change, _ ->
                        val dx = change.position.x - size.center.x
                        val dy = change.position.y - size.center.y
                        val newSaturation = (hypot(dx, dy) / (size.width / 2)).coerceIn(0f, 1f)
                        var angle = Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat()
                        if (angle < 0) { angle += 360f }
                        hue = 360f - angle
                        saturation = newSaturation
                    }
                }
        ) {
            Canvas(
                modifier = Modifier
                    .fillMaxSize()
            ) {
                val radius = size.minDimension / 2
                val center = size.center
                val selectorRadius = radius * saturation
                val angleRad = Math.toRadians(hue.toDouble())
                val selectorX = center.x + selectorRadius * cos(-angleRad).toFloat()
                val selectorY = center.y + selectorRadius * sin(-angleRad).toFloat()
                
                drawCircle(
                    brush = Brush.sweepGradient(
                        listOf(Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red)
                    )
                )
                drawCircle(
                    brush = Brush.radialGradient(
                        colors = listOf(Color.White, Color.Transparent),
                        center = center,
                        radius = radius
                    )
                )
                
                drawCircle(
                    color = Color.Black,
                    radius = 12f,
                    center = Offset(selectorX, selectorY)
                )
                drawCircle(
                    color = Color.White,
                    radius = 10f,
                    center = Offset(selectorX, selectorY)
                )
            }
        }
        Row(
            horizontalArrangement = Arrangement.spacedBy(20.dp),
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier
                .fillMaxWidth()
                .padding(start = 24.dp, top = 12.dp, end = 24.dp)
        ) {
            Slider(
                state = brightness,
                thumb = {
                    Canvas(
                        modifier = Modifier
                            .size(24.dp)
                    ) {
                        drawCircle(
                            color = Color.Transparent,
                            radius = 8.dp.toPx(),
                            center = size.center
                        )
                        drawCircle(
                            color = Color.White,
                            radius = 9.dp.toPx(),
                            center = size.center,
                            style = Stroke(2.5.dp.toPx())
                        )
                    }
                },
                track = {
                    Canvas(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(24.dp)
                    ) {
                        drawRoundRect(
                            brush = Brush.horizontalGradient(listOf(hsvToColor(hue, saturation, 0f), hsvToColor(hue, saturation, 1f))),
                            topLeft = Offset(-size.height / 2, 0f),
                            size = Size(size.width + size.height, size.height),
                            cornerRadius = CornerRadius(12.dp.toPx())
                        )
                    }
                },
                modifier = Modifier
                    .weight(9.5f)
            )
            Text(
                text = "${(brightness.value * 100).toInt()}",
                fontSize = 14.sp,
                fontWeight = FontWeight.Bold,
                textAlign = TextAlign.End,
                modifier = Modifier
                    .weight(1f)
            )
        }
        Row(
            horizontalArrangement = Arrangement.spacedBy(20.dp),
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 24.dp)
        ) {
            Slider(
                state = alpha,
                thumb = {
                    Canvas(
                        modifier = Modifier
                            .size(24.dp)
                    ) {
                        drawCircle(
                            color = Color.Transparent,
                            radius = 8.dp.toPx(),
                            center = size.center
                        )
                        drawCircle(
                            color = Color.White,
                            radius = 9.dp.toPx(),
                            center = size.center,
                            style = Stroke(2.5.dp.toPx())
                        )
                    }
                },
                track = {
                    Canvas(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(24.dp)
                    ) {
                        drawRoundRect(
                            brush = Brush.horizontalGradient(listOf(selectedColor.copy(alpha = 0f), selectedColor)),
                            topLeft = Offset(-size.height / 2, 0f),
                            size = Size(size.width + size.height, size.height),
                            cornerRadius = CornerRadius(12.dp.toPx())
                        )
                    }
                },
                modifier = Modifier
                    .weight(9.5f)
            )
            Text(
                text = "${(alpha.value * 100).toInt()}",
                fontSize = 14.sp,
                fontWeight = FontWeight.Bold,
                textAlign = TextAlign.End,
                modifier = Modifier
                    .weight(1f)
            )
        }
    }
}

• noRippleClickable()은 이 글을 참고하면 된다.

 

• selectedColor는 팔레트에서 드래그할 때 매우 빠르게 변하는 값이므로 derivedStateOf를 사용하였다.

 

var color by remember { mutableStateOf(Color.Red) }
val colorList = remember { mutableStateListOf<Color>() }

Column(
	modifier = Modifier
        .fillMaxSize()
        .background(Color.White)
        .statusBarsPadding()
) {
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .fillMaxWidth()
            .height(48.dp)
    ) {
        IconButton(
            onClick = onDismiss,
            modifier = Modifier
                .align(Alignment.CenterStart)
                .padding(start = 4.dp)
        ) {
            Icon(
                imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_left_ios),
                contentDescription = "뒤로가기"
            )
        }
        Text(
            text = "색상 선택",
            style = TextStyles.titleTS // 18sp, Medium
        )
    }
    Column(
        modifier = Modifier
    ) {
        ColorPicker(
            initialColor = color,
            onAddColor = { colorList.add(it) }
        )
        LazyVerticalGrid(
            columns = GridCells.Fixed(6),
            verticalArrangement = Arrangement.spacedBy(8.dp),
            contentPadding = PaddingValues(horizontal = 20.dp),
            modifier = Modifier
                .weight(1f)
                .padding(vertical = 20.dp)
        ) {
            items(colorList) { colorChoice ->
                val tooltipState = rememberTooltipState()
                TooltipBox(
                    positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
                    state = tooltipState,
                    tooltip = {
                        PlainTooltip(
                            shape = CircleShape,
                            modifier = Modifier
                                .noRippleClickable { colorList.remove(colorChoice) }
                        ) {
                            Icon(
                                imageVector = ImageVector.vectorResource(R.drawable.ic_delete_thin),
                                contentDescription = "색상 삭제"
                            )
                        }
                    }
                ) {
                    Box(
                        contentAlignment = Alignment.Center,
                        modifier = Modifier
                            .fillMaxWidth()
                            .noRippleClickable { color = colorChoice }
                    ) {
                        Canvas(
                            modifier = Modifier
                                .size(40.dp)
                        ) {
                            drawCircle(colorChoice)
                            if (colorChoice == color) {
                                drawCircle(
                                    color = Color.White,
                                    radius = 17.dp.toPx(),
                                    style = Stroke(3.5.dp.toPx())
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}

 

추가로 색상 목록도 필요한 사람을 위해 설명한다.

val color = listOf(
    Color(0xFFFF0000), Color(0xFFFF4500), Color(0xFFFF7F00), Color(0xFFFFA500), Color(0xFFFFFF00), Color(0xFFBFFF00),
    Color(0xFF00FF00), Color(0xFF4CAF50), Color(0xFF26D9C3), Color(0xFF00FFFF), Color(0xFF008B8B), Color(0xFF0000FF),
    Color(0xFF4169E1), Color(0xFF3F51B5), Color(0xFF8B00FF), Color(0xFF9C27B0), Color(0xFFE91E63), Color(0xFFFF00FF),
    Color(0xFFFFC107), Color(0xFF795548), Color(0xFFA9A9A9), Color(0xFFB0B0B0), Color(0xFFD3D3D3), Color(0xFF000000),
    Color(0xFFFFCDD2), Color(0xFFF8BBD0), Color(0xFFE1BEE7), Color(0xFFD1C4E9), Color(0xFFC5CAE9), Color(0xFFBBDEFB),
    Color(0xFFB3E5FC), Color(0xFFB2EBF2), Color(0xFFB2DFDB), Color(0xFFC8E6C9), Color(0xFFDCEDC8), Color(0xFFF0F4C3),
    Color(0xFFFFF9C4), Color(0xFFFFECB3), Color(0xFFFFE0B2), Color(0xFFFFCCBC)
).map { it.toHexString() }

 

 

마치며

여러 가지 발생할 수 있는 상황을 예측해 예외 처리를 하였지만 미처 예상하지 못한 상황이 발생할 수 있기에 그런 상황을 발견한다면 알려주시면 감사하겠습니다.

그 외에도 궁금한 점이나 피드백은 댓글이나 카톡으로 편하게 연락해 주시면 최대한 빠르게 답변해 드리겠습니다. 

https://open.kakao.com/o/syg9ngUf

 
'Jetpack Compose' 카테고리의 다른 글
  • [Jetpack Compose] Navigation Bar 커스텀하기
  • [Jetpack Compose] Navigation Indicator 만들기
  • [Jetpack Compose] Scroll Picker 만들기
  • [Jetpack Compose] 버전 문제로 인한 오류, 안정화 버전으로 해결하기
브애애앳
브애애앳
  • 브애애앳
    디벨로퍼즐
    브애애앳
  • 전체
    오늘
    어제
    • 분류 전체보기 (21)
      • Jetpack Compose (15)
      • Kotlin (3)
      • Android Studio (2)
      • Tistory (1)
  • 인기 글

  • hELLO· Designed By정상우.v4.10.0
브애애앳
[Jetpack Compose] Color Picker 만들기
상단으로

티스토리툴바