Android Custom Color Picker

You are currently viewing Android Custom Color Picker

Below I will show you how to create a simple color picker in Android Studio with Jetpack Compose.

The element used is a Canvas that captures the touch motion.

This is a rough sketch I just put together. There is more customization needed to implement into your own project / use case.

I included some basic debugging information in the form of visual Text elements.

You can create a callback in the ColorPicker Composable and return the value captured if needed for further processing.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            SampleColorPickerTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->

                    ColorPicker(
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

@Composable
fun ColorPicker(modifier: Modifier = Modifier) {

    var dragOffset by remember {
        mutableStateOf(Offset.Zero)
    }
    var mSize by remember {
        // preset size of view
        mutableStateOf(IntSize(360, 100)) 
    }

    var color: List<Int> = (0..mSize.width).toList()
    val colorList = color.map { color[it].toFloat() }
    
    var hueList: List<Color> = emptyList()

    //reset view size to fill maxWidth after composition
    if (mSize != IntSize.Zero) {
        hueList =
            colorList.map { Color.hsl(getFloatFromWidth(it, mSize.width), 1f, 0.5f) }
    }
    Column(modifier = modifier) {
        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .height(100.dp)
                .onGloballyPositioned {
                    mSize = it.size
                }
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        dragOffset = change.position
                    }
                },
            onDraw = {
                drawRoundRect(
                    brush = Brush.horizontalGradient(
                        hueList,
                    ),
                    topLeft = Offset.Zero,
                    size = size
                )
                drawCircle(
                    color = Color.White,
                    radius = 35f,
                    center = dragOffset,
                    style = Stroke(width = 5f)
                )
            }
        )

        Text(text = "${dragOffset.x}, ${dragOffset.y}")
        if (hueList.isNotEmpty()) {
            Row {
                Text(text = "${dragOffset.x.roundToInt()}", textAlign = TextAlign.End)
            }
            Row(
                modifier = Modifier
                    .size(100.dp)
                    .padding(15.dp)
                    .background(
                        hueList[dragOffset.x.roundToInt()],
                        shape = RoundedCornerShape(50.dp)
                    )
            ) {

            }
        }
    }
}

fun getFloatFromWidth(it: Float, mSize: Int): Float {
    return ((it / mSize) * 360f)
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    SampleColorPickerTheme {
        ColorPicker()
    }
}

UPDATE

I wanted to elaborate a little more on this. I wanted to be able to quickly add the saturation and the light adjustment. As pictured above, the old version is on the left and the new one is on the right.

After creation, I realized I overlooked the fact that there is no dark or light version of colors including full black or full white.

The simplest way I figured to do this is create a separate Rect above the color picker Rect in the canvas. This has a verticalGradient applied to it with color stops of

drawRoundRect(
                    brush = Brush.verticalGradient(
                        0.0f to Color.Black,
                        0.5f to Color.Transparent,
                        1.0f to Color.White,
                        startY = Float.MIN_VALUE,
                        endY = Float.POSITIVE_INFINITY,
                        tileMode = TileMode.Clamp
                    ),
                    topLeft = Offset.Zero,
                    size = size
                )

This is FAR from a proper way to implement but I wanted to create a quick fix without changing too much of the existing code.

A better way to implement is create a separate entity to hold the change for saturation and also another one to hold the change in light.

I did however create a callback to show how you can retrieve the Color value.

Here is the new version

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            SampleColorPickerTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->

                    ColorPicker(
                        modifier = Modifier.padding(innerPadding)
                    ) { retrieveSelectedColor ->

                    }
                }
            }
        }
    }
}

@Composable
fun ColorPicker(modifier: Modifier = Modifier, retrieveSelectedColor: (Color) -> Unit) {

    var dragOffset by remember {
        mutableStateOf(Offset.Zero)
    }
    var mSize by remember {
        mutableStateOf(IntSize(360, 100))
    }

    var activeColor by remember {
        mutableStateOf(Color.White)
    }

    var color: List<Int> = (0..mSize.width).toList()
    val colorList = color.map { color[it].toFloat() }

    var hueList: List<Color> = emptyList()

    if (mSize != IntSize.Zero) {
        hueList =
            colorList.map { Color.hsl(getFloatFromWidth(it, mSize.width), 1f, 0.5f) }
    }
    Column(modifier = modifier) {
        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .height(100.dp)
                .onGloballyPositioned {
                    mSize = it.size
                }
                .pointerInput(Unit) {
                    detectTapGestures(
                        onPress = { position ->
                            dragOffset = position
                            activeColor = Color.hsl(
                                (dragOffset.x / mSize.width) * 360f,
                                getFloatFromHeight(dragOffset.y, mSize.height.toFloat()),
                                getFloatFromHeight(dragOffset.y, mSize.height.toFloat()),
                                1.0f
                            )
                            retrieveSelectedColor(activeColor)
                        }
                    )
                }
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        dragOffset = change.position
                        //below catches if the current position is out of bounds and keeps it in the Rect
                        if (dragOffset.x > size.width) dragOffset = dragOffset.copy(x = size.width.toFloat())
                        if (dragOffset.x < 0) dragOffset = dragOffset.copy(x = 0f)
                        if (dragOffset.y > size.height) dragOffset = dragOffset.copy(y = size.height.toFloat())
                        if (dragOffset.y < 0) dragOffset = dragOffset.copy(y = 0f)
                        //here is the change, its a rough sketch that should be implemented better but this works
                        activeColor = Color.hsl(
                            (dragOffset.x / mSize.width) * 360f,
                            getFloatFromHeight(dragOffset.y, mSize.height.toFloat()),
                            getFloatFromHeight(dragOffset.y, mSize.height.toFloat()),
                            1.0f
                        )
                        //callback to hand current color to hand off current color
                        retrieveSelectedColor(activeColor)
                    }
                },
            onDraw = {
                drawRoundRect(
                    brush = Brush.horizontalGradient(
                        hueList,
                    ),
                    topLeft = Offset.Zero,
                    size = size
                )
                //change to simulate the saturation and light transition, could be done better with another Compoosable function
                drawRoundRect(
                    brush = Brush.verticalGradient(
                        0.0f to Color.Black,
                        0.5f to Color.Transparent,
                        1.0f to Color.White,
                        startY = Float.MIN_VALUE,
                        endY = Float.POSITIVE_INFINITY,
                        tileMode = TileMode.Clamp
                    ),
                    topLeft = Offset.Zero,
                    size = size
                )
                drawCircle(
                    color = Color.White,
                    radius = 35f,
                    center = dragOffset,
                    style = Stroke(width = 5f)
                )
            }
        )

        Text(text = "${dragOffset.x}, ${dragOffset.y}")
        if (hueList.isNotEmpty()) {
            Row {
                Text(text = "${dragOffset.x.roundToInt()}", textAlign = TextAlign.End)
            }
            Row(
                modifier = Modifier
                    .size(100.dp)
                    .padding(15.dp)
                    .background(
                        activeColor,
                        shape = RoundedCornerShape(50.dp)
                    )
            ) {

            }
        }
    }
}

fun getFloatFromWidth(it: Float, mSize: Int): Float {
    return ((it / mSize) * 360f)
}

fun getFloatFromHeight(it: Float, mSize: Float): Float {
    var sat_lght = (it / mSize)
    //keep variable in line between 0.0f and 1.0f
    if (sat_lght > 1.0f) sat_lght = 1.0f
    if (sat_lght < 0f) sat_lght = 0f
    return sat_lght
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    SampleColorPickerTheme {
        ColorPicker() { retrieveSelectedColor ->

        }
    }
}

Leave a Reply