Recently our designer came with this gorgeous selector to pick color values on a linear scale:
Even though it might look complicated, it is pretty simple once you break it down into separate components. In this article, I am going to walk you through the process of creating it from scratch.
The whole implementation can be divided into four components:
So let’s start with the first one, shall we? Actually before that, let’s lay down some ground work which will help us to write less code.
We will write a nifty observable property so that we do not have to call invalidate()
on our view every time we update a property.
1private fun <T> viewProperty(default: T) = object : ObservableProperty<T>(default) {23 override fun beforeChange(property: KProperty<*>, oldValue: T, newValue: T): Boolean =4 newValue != oldValue56 override fun afterChange(property: KProperty<*>, oldValue: T, newValue: T) {7 postInvalidateOnAnimation()8 }9}
Now instead of writing var radius: Float = 0F
, we write var radius: Float by viewProperty(0f)
. Whenever the radius
changes, the view will be invalidated automatically. This is particularly useful during animation as you’ll see shortly.
Before we get to any drawing, we will need to determine how big our view will be. For SlideColorPicker
, I decided to limit it to the size of the circle. To draw the gradient slider, we will draw outside our bounds by adding android:clipChildren=”false”
to our parent layout. Now then, let’s get to drawing!
Firstly, we will need a stroked circle which will be dragged along the vertical axis to select the color. It can be drawn easily by writing:
1canvas.drawCircle(centerX, centerCircleY, radius, circlePaint)
centerX
is equal to width / 2
of the view. Since the circle will only be translated along the vertical axis, this value will never change. Similarly, circlePaint
will be unchanged. The two interesting properties here are centerCircleY
and the radius
. Since changing them will require the view to be redrawn, they are defined using our viewProperty
delegate.
Next up is the gradient slider. It can be achieved using a rectangle; a rounded rectangle to be precise.
1canvas.drawRoundRect(2 left = centerX - originalRadius,3 top = centerY - halfRectHeight,4 right = centerX + originalRadius,5 bottom = centerY + halfRectHeight,6 originalRadius,7 originalRadius,8 rectanglePaint)
halfRectHeight
will be changing on each draw call. It varies from originalRadius
to the expanded height, which is equal to originalRadius * heightMultiplier
. The following image should help in visualizing the values.
You will notice that we are using drawRoundRect
instead of drawCircle
to draw the colored circle in the collapsed state. It is just that the radius of the corners is equal to height/2
of the rectangle which is why it looks like a circle.
As for rectanglePaint
, we apply a gradient to it when we are in the expanded state whereas in the collapsed state, we just show the selected color.
We will end up with something like this
Now comes the most interesting part — animation!
Following our divide and conquer principle, we need to do 2 things when the user touches the screen
Furthermore, we want these animations to play in tandem. So, when the scale animation is at half the progress, so should be the expand animation. And when the user lifts up their finger, we want to reverse the animation.
For this reason, we will be using a ValueAnimator which goes from 0 to 1 and observe the values using an update listener. First things first, let’s define an enum which will help us to identify whether the touch event is DOWN
or UP
.
1enum class TouchAction {2 DOWN, UP3}
Now, using the touch listener, we can play the animation whenever the user interacts with our view.
1when (event.action) {2 MotionEvent.ACTION_DOWN -> {3 runAnimation(TouchAction.DOWN)4 }5 MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {6 runAnimation(TouchAction.UP)7 }8}
Finally, let’s implement the function which performs the animation.
1private fun runAnimation(action: TouchAction) {2 animator.apply {3 removeAllUpdateListeners()4 cancel()5 addUpdateListener {6 val value = it.animatedValue as Float7 animateCircleScale(value, action)8 animateRectangle(value, action)9 if (action == TouchAction.UP) {10 animateCircleToCenter(centerCircleY, value)11 }12 }13 start()14 }15}
A bit of boilerplate here, but the primary functions we are concerned with are animateCircleScale
, animateRectangle
and animateCircleToCenter
. Let’s walk through them one by one:
animateCircleScale- It is responsible for (no points for guessing) animating the circle’s scale.
1private fun animateCircleScale(2 animatorValue: Float,3 action: TouchAction4) {5 val (startRadius, endRadius) = if (action == TouchAction.UP) {6 scaledDownCircleRadius to originalRadius7 } else {8 originalRadius to scaledDownCircleRadius9 }10 radius = lerp(startRadius, endRadius, animatorValue)11}
As you can see, we decide the start radius and end radius
based on the user’s action, and then just assign the lerp-ed value to radius
. And since radius is an observable view property, we do not need to call invalidate()
manually!
If you are wondering what the lerp function does, check out this great explanation. I will be perfectly honest, I had seen this function before in other animation tutorials, but it didn’t quite click for me until I had to use it myself 😁
animateRectangle- No points for guessing (didn’t he just use this phrase?) what this function does — it updates the halfRectHeight
property which in turn triggers the re-drawing of the view. Doing this each time the value animator updates results in the effect of expanding slider as seen in the animation.
1private fun animateRectangle(2 animatorValue: Float,3 action: TouchAction4) {5 val (startHeight, endHeight) = if (action == TouchAction.UP) {6 expandedHeight to originalRadius7 } else {8 originalRadius to expandedHeight9 }10 rectHeight = lerp(startHeight, endHeight, animatorValue)11}
animateCircleToCenter- You won’t get any points for guessing what this function does (this is the third time, what are these writing skills) — it animates the circle back to the center when the user lifts their finger. This is achieved by
1centerCircleY = lerp(currentY, centerY, animatorValue)
We will also need to add the gradient to the rectangle paint on ACTION_DOWN
. This is done using a linear gradient
1val gradient = LinearGradient(2 x0 = 0F,3 y0 = originalRadius - expandedHeight,4 x1 = 0F,5 y1 = originalRadius + expandedHeight,6 startColor,7 endColor,8 Shader.TileMode.MIRROR9)
It’s height will be equal to the height of our view in expanded state. Then we just apply it to the paint like so
1colorGradientPaint.shader = gradient
We now have something like this
We are slowly but surely getting there! 🙌
Now let’s add drag support for the circle. Simply add an additional branch to the when expression in onTouch
method for ACTION_MOVE
and set centerCircleY
to the Y value of the motion event.
Lastly, let’s update the selected color.
1private fun setSelectedColor(position: Float) {2 val progress = (position - upperBound) / (lowerBound - upperBound3 val colorRes = ArgbEvaluatorCompat.getInstance().evaluate(progress, startColor, endColor)4 colorGradientPaint.shader = null5 colorGradientPaint.color = colorRes6}
We calculate at what point the user lifts their finger along the gradient rectangle. Then we evaluate what color will that progress value evaluate to using the ARGB Evaluator. Once we calculate that color, we set apply that to the rectanglePaint
and remove the gradient.
Just a little disclaimer, this view doesn’t let you pick the exact values and nor does it aim to (you’ll find that the maths reflects that 😅). The current implementation is what we wanted. You can easily modify parts of this code such that the color selection becomes more precise.
So that’s it! This is the first time I have tried to implement a custom view with this many moving parts. It was a really interesting experience so I thought I might share it. I was thinking about creating a library but decided against that due to the limited usecase. Nevertheless, here is a repo containing the full code.
Thanks for sticking around till the end, hope you learned something!👋