Android Custom Color Picker
Create a Simple Color Picker in Android Compose using a Canvas Composable.

In this post I will show you how to create a simple Android custom color picker in Compose.

Android Color Picker

Process

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 composables.

You can create a callback in the Android custom color picker composable and return the value captured if needed for further processing. An example of a callback implementation is located at bottom of post.

The color creation is an array based on the size of the screen. The colors are added to it using Color.hsl()Β with the Float value calculated from the given part of the screen. Meaning, the first part of the function requires a float value 0.0F to 1.0F, coordinate of middle of screen is 0.5F. Then the function would be used as Color.hsl(0.5f, 1f, 0.5f)Β  to get the color.

Code

MainActivity

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

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

ColorPicker

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

Update

After creation, I realized I overlooked the fact that there is no dark or light version of colors including full black or full white. As pictured below, the old version is on the left and the new one is on the right.

I needed to quickly add the saturation and the light adjustment of the colors. This would add the dark and light variations of the displayed colors.

Fix

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 shown below.

                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.

Android Color Picker - 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 ->
                        //retrieveSelectedColor  is the value of the selected color
                    }
                }
            }
        }
    }
}

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

Hope this was useful.