For every Android developer comes a day where the Android built-in views are not enough. One day your designer or project-manager or client or even you need a unique feature. That single feature embodies a particular view that does not exist naturally in the Android world.
The first and correct instinct is to search for a library that does the trick. However, there comes a time where no library meets our standards, and even if one does, it is not exactly what we need.
Eventually, you will be forced to modify an existing library, assuming it is open source, or create something entirely new.
In this article, I will introduce you to the world of custom views and hopefully shed some of the ambiguity of this world that makes it look so daunting.
During the article, we will create our custom view step by step. The result will be a ViewPager page indicator.
I will cover all the necessary lifecycle methods in this article separately step by step. Please note that this is not the full lifecycle. The complete list of methods is irrelevant since we can only access the ones I mentioned here. Some methods that are not mentioned can be accessed but do not seem to be built for that purpose, and therefore they are not discussed here either.
This part is self-explanatory. We will override all the constructors and create a single method to receive the attributes. We will get back to that method later on.
There are a total of four constructors because there are four ways for our view to be created:
- Inflated programmatically in code.
- Created when inflating a view from an XML.
Indicator(Context context, AttributeSet attrs)
- Created when inflating a view from an XML and applying a class-specific base style from a theme attribute.
Indicator(Context context, AttributeSet attrs, int defStyleAttr)
- Created when inflating a view from an XML and applying a class-specific base style from a theme attribute or a style resource. This constructor is from API 21.
Indicator(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)
Measurements — Rules
Measurements are the most crucial step in creating a custom view. It is similar to marking with a pencil each circle location, right before drawing it there. And just as we don’t want to recalculate each time we draw, so does the Android operating system.
There are three essential things to know before starting this step:
- Axes: The x,y axes are starting from the topmost left corner. If you haven’t guessed already, the heavy lifting of creating a custom view is creating an algorithm to map the coordinates of each component. Of course, the complexity differs from each custom view.
The values of the X-axis increase as you go right on the axes. However, unlike we were taught at school, the values of Y are increasing as we go from top to bottom. That is very important and will save you a lot of headaches.
- Screen Sizes: Android screen sizes come in different sizes. Therefore, each value needs to be treated in its DP or SP form when initialized. During calculation, these values must be translated to pixels.
- System Parameters: Unless you are the sole user of the custom views, you must not forget about padding and width\height. Would it not be weird if you added “match_parent” in the width section but the view would not stretch to the edges of the screen?
On a side note: from API 17 a user can define a paddingStart, paddingEnd XML attribute. The methods getRightPadding(), getLeftPadding() are sufficient since they factor in these attributes.
Having the three crucial steps in our mind, we can begin.
Measurements — Theory
Each view has an onMeasure method. This method is called each time the view is measured. The two parameters that are received are the stats of our width and height, and they contain the information written in our layout_width and layout_height in the layout XML where our view resides.
Each value represents the specSize and specMode.
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
specMode has three possible values:
- MeasureSpec.EXACTLY: Represents a ‘match_parent’ value or a hard-coded value such as ‘50dp’. It means that the user or the container of the view expect the view to stretch to the given measurements without any consideration of our calculations.
- MeasureSpec.AT_MOST: Represents a ‘wrap_content’ value. It means that the user or the container of the view allow us to decide what the width and height will be.
- MeasureSpec.UNSPECIFIED: Represents a view that is in a ScrollView which means that the user or container has no idea what width and height to adjust for our view. It is very similar to MeasureSpec.AT_MOST and therefore I treat both the same.
specSize represents the requested size of the view by the user or by the view container in pixels and should usually be used with MeasureSpec.EXACTLY since this is the exact width\height the container or the user asks for.
Measurements — Practice
In most cases, to measure the width and height of a custom view, we first need to measure its components and find the components x and y coordinates. These measurements, in turn, would provide to us the height and width of our custom view.
Once the calculations are complete, we pass our width and height values to the setMeasuredDimension method. This method will give our view the calculated width and height.
Each custom view has different calculations. Therefore I did not include this part in the code example. I will say that our calculation method creates an array of positions of the circles. Each position is represented by a different x coordinate. Also, there is a single y coordinate that is common to all since they are all parallel to each other. These x and y coordinates pinpoint the exact center of each of our indicator circles.
If you want to take a look at the calculations, I have linked at the end of the article the GitHub repository of this articles code example.
Child Views — onLayout()
If our view contains child views, this is where we measure them. In our case, we don’t have any child views, so this step is not relevant to us. However, in theory, we could create each circle as a child view and calculate them at this step.
Drawing — Rules
There is a single very important rule to draw. The specific method that is called when we need to form our components, onDraw(), can be called a lot of times. Every little change in our view will result in this method call. Therefore, we must not do any memory allocations or calculations. This process needs to be fast and efficient.
Drawing — Theory & Practice
Just like a professional painter, we have Canvas and Paint objects to draw our components. The canvas is received as a parameter in the overridden method onDraw(), and the paint objects need to be created by us.
The Paint object is like a bucket of color. The Paint object constructor receives a flag that describes how the Paint object will be drawn when it is used. Different flags exist for different views; some are adjusted to text, some to geometry and some for both.
Each Paint object expects a particular style when its color is being used.
Both of these are out of the scope of this article since Google’s documentation explains them very well.
Canvas is a whole world by itself. With the correct parameters, it can do anything from drawing a circle to drawing text. Each view has its complexity and required components, and the usage of the Canvas object differs according to that.
This step differs from custom view to custom view. Some custom views such as our Indicator need to change and adapt to different parameters and events and some might not even need it.
Two methods can send us to a certain point in the lifecycle event of a view:
- invalidate(): Calls the drawing process to happen again. Therefore, this method should be used when the state of the view has changed, but not its size.
In our case, whenever the page is changed we must update the indicator, and we do so by calling the invalidate() method.
- requestLayout(): Calls the entire lifecycle events to occur again. Therefore, this method should be used when the size or dimensions of our custom views change in a way that forces us to remeasure it.
For example, if our view contains child views, and one of the child measurements pushes it out of the view in the onLayout() method, we will call requestLayout() to recalculate the width and height of the view all over.
Attributes — Custom Parameters
Congratulations! We have a drawn an indicator view. However, the values we entered so far were hard-coded, and as a result, our view is not reusable. To allow the user to choose the values in the XML declaring the view, we must use attributes.
How do we create a custom attribute for our view? All we need to do is create a file named attr.xml and place it in the values folder. This XML file is the home of all the custom views parameters. Therefore, if the file exists we just add the view tags.
There is a variety of parameter types to choose from: boolean, color, dimension, enum, flag, float, fraction, integer, reference, and string. Most of the time only a select few of the parameters are relevant for us. The indicator custom view we are creating will need the next parameters:
- radius of the circle
- distance between each circle
- number of circles
- color of the chosen circle
- color of the default circle
If your custom view has attributes that represent a value that is vital for calculations, like our radius or distance between each circle parameters, I recommend to accept it as a dimension value. A dimension value forces the user of the view to enter a value that is in SP or DP.
The rest of the attributes parameter types are self-explanatory.
An important thing to note is that the dimension type parameter that is received as a DP or SP value in the XML layout file is automatically converted to pixels when extracted from the attribute data collection.
Custom views may seem intimidating at first, but as you dive in and analyze them, they are quite simple to master. So next time you are considering a custom view don’t be afraid — it is doable.
All the code can be found in: