Creating Android custom view

Daniele Bottillo
6 min readApr 1, 2019

--

One of my favourite part of writing an Android custom view is when you add value to the UI & UX of an application. Recently, as part of the development of an open source Android (and iOS) application of mine I’ve developed the following indicator:

For some context, MTG Cards Info is an open source Android (and iOS!) application whose main purpose is to browse Magic The Gathering trading cards (https://github.com/dbottillo/MTGCardsInfo).

In MTG world, a card can have up to five colours (white, blue, black, red and green) and each deck is made of 60 cards. Each deck is usually defined by the colours of the cards it contains. For example, a blue-white deck or a white-green-red deck. I decided to create a custom view which shows the colour of each deck, as displayed in the previous screenshot (right picture): the UB discard deck is made of blue and black cards and the Gruul deck is made of red and green cards.

Considering that there are five colours, an indicator can have the following five states (with different colour combinations of course):

Let’s start defining the custom view first:

class IndicatorView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

Nothing new here, just remember to use the@JvmOverloads annotation that is required when you create the class in Kotlin if you want to define one constructor instead of multiple ones like in Java.

The core logic of a View class is done in the onDraw method: Android provides you a Canvas object and then you can draw on it like a painter :) There are different ways to draw on canvas: you can draw a point, a line, a circle, etc.. As part of this custom view, because we need to draw a circle made of different segments, we mostly need the drawArc method:

public void drawArc(RectF oval, 
float startAngle,
float sweepAngle,
boolean useCenter,
Paint paint)

The drawArc method requires a RectF object which you can think of as the rectangle that encloses the area where you want to draw the arc; also, you need to specify the startAngle, the sweepAngle, the useCenter if you want to fill the arc or not and the paint object.

The paint object is the way to define how to draw the arc: if you want to fill the area or just want to draw on the border for example. As part of this custom view we just want to draw a filled arc, so the paint can be very simple:

private val paint: Paint = Paint()
init {
paint.style = Paint.Style.FILL
paint.isAntiAlias = true
paint.color = ContextCompat.getColor(context, R.color.a_color)
}

We are defining the paint style as FILL(the other options would be STROKE and FILL_STROKE but we are not interested in the stroke for now), set the anti alias in order to have smooth edges, and the actual colour.

We know that we have up to five arcs to draw - with a minimum of one - and that they are based on the number of colours‘s deck.
Let’s add a property to define the colours to be used into the custom view:

var colors: List<Color> = emptyList()
set(value) {
if (field != value || arcs.isEmpty()) {
field = value
computeArcs()
}
}

The idea behind this field is that every time we set the colours on the custom view we re-compute the arcs that need to be drawn.

For clarity and simplicity, let’s define an Arc object:

class Arc(val start: Float, 
val sweep: Float,
val color: Int)

As you would expect, an Arc object has a start, a size (sweep) and a colour because we know that each arc can have one of the five colours.

Colours are determined by the following enumeration and map:

enum class Color {
WHITE, BLUE, BLACK, RED, GREEN
}
private val colorMap = mapOf(
Color.WHITE to ContextCompat.getColor(context, R.color.white),
Color.BLUE to ContextCompat.getColor(context, R.color.blue),
Color.BLACK to ContextCompat.getColor(context, R.color.black),
Color.RED to ContextCompat.getColor(context, R.color.red),
Color.GREEN to ContextCompat.getColor(context, R.color.green))

The colorMap is just a helper to map one colour to the actually Android definition of the colour.
Let’s have a look at the computerArcs() method now:

private fun computeArcs() {
arcs = if (colors.isEmpty()) {
listOf(Arc(0f, 360f, greyColor))
} else {
val sweepSize: Float = 360f / colors.size
colors.mapIndexed { index, color ->
val startAngle = index * sweepSize
Arc(start = startAngle,
sweep = sweepSize,
color = colorMap.getValue(color))
}
}
invalidate()
}

This is where the magic about the arcs happens: we know that the size of each arc is 360f / number of colours and then for each colour we create the corresponding arc.

Great! So, now we have all the arcs ready to be drawn for the onDraw method:

override fun onDraw(canvas: Canvas) {
arcs.forEach { arc ->
paint.color = arc.color
canvas.drawArc(oval = rect,
startAngle = START_ANGLE + arc.start,
sweepAngle = arc.sweep,
useCenter = true,
paint = paint)
}
}

That’s very easy now :) The idea behind computing the arcs when setting the colours is to avoid doing any calculation in the onDraw method because that would slow down the performance.

A small note around the START_ANGLE: when drawing an arc on a canvas, if the start angle is 0f the arc will start at 3 o’clock on a watch but for this specific custom view I want it to start at 10:45 o’clock instead, so adding

private const val START_ANGLE = 225f

to the startAngle it will actually move the starting point to the right place because the drawArc method calculates the start angle as a modulo of 360.

One last thing is to define the RectF: as I mentioned before, it’s not recommended to do any calculation in the onDraw method, so we can set the size of the rectangle in the onSizeChanged instead, which gets called only when the view changes in size:

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
rect.set(0f, 0f, w.toFloat(), h.toFloat())
}

You can find the full implementation on here: https://github.com/dbottillo/Blog/tree/indicator_view

The full IndicatorView class is:

class IndicatorView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

private val rect: RectF = RectF(0f, 0f, 0f, 0f)

var colors: List<Color> = emptyList()
set(value) {
if (field != value || arcs.isEmpty()) {
field = value
computeArcs()
}
}

private val paint: Paint = Paint()
private val greyColor = ContextCompat.getColor(context, R.color.mtg_other)
private var arcs = emptyList<Arc>()

private val colorMap = mapOf(Color.WHITE to ContextCompat.getColor(context, R.color.mtg_white),
Color.BLUE to ContextCompat.getColor(context, R.color.mtg_blue),
Color.BLACK to ContextCompat.getColor(context, R.color.mtg_black),
Color.RED to ContextCompat.getColor(context, R.color.mtg_red),
Color.GREEN to ContextCompat.getColor(context, R.color.mtg_green))

private fun computeArcs() {
arcs = if (colors.isEmpty()) {
listOf(Arc(0f, 360f, greyColor))
} else {
val sweepSize: Float = 360f / colors.size
colors.mapIndexed { index, color ->
val startAngle = index * sweepSize
Arc(start = startAngle, sweep = sweepSize, color = colorMap.getValue(color))
}
}
invalidate()
}

init {
paint.style = Paint.Style.FILL
paint.isAntiAlias = true
computeArcs()
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
rect.set(0f, 0f, w.toFloat(), h.toFloat())
}

override fun onDraw(canvas: Canvas) {
arcs.forEach { arc ->
paint.color = arc.color
canvas.drawArc(rect, START_ANGLE + arc.start, arc.sweep, true, paint)
}
}
}

private const val START_ANGLE = 225f

private class Arc(val start: Float, val sweep: Float, val color: Int)

enum class Color {
WHITE, BLUE, BLACK, RED, GREEN
}

--

--

Daniele Bottillo

Android@Monzo Bank (London), technology enthusiast, book reader, TV shows fan, game player, Lego addicted.