Numerical grid
Download the source of this article.
1. Introduction
Android applications consist of activities. An activity is a sort of form hosting several entities known as views (or sometimes widgets). Views are the basic elements of the user interface; examples of views are buttons, textviews or checkboxes.
In this article we are going to develop a view ourselves. We'll try to make it look and behave like a stock android view: it should have properties, xml attributes, listeners etc. Just like the real thing.
2. Specification
The view we are going to make will be called NumGridView. A NumGridView is view that renders a grid with numbers. To test the view, we will make an activity with a NumGridView. Each time a cell is touched, the cell value will be incremented.
The demo application, hosting a NumGridView of 5 by 5 cells
We aim at the following features for our NumGridView:
- The view can be used in the layout xml file of an activity.
- The view has view-specific attributes (number of cells horizontally and vertically) in the xml file.
- The view has properties (setters and getters) for the values in the cell.
- The view has a cell touch listener.
- The view should determine its overall size (in the view hierarchy of the actvity).
- The view should determine the size of a single cell, so that all cells fit in the overall size.
- The view should determine the font size for the numbers in its cells.
- The view should draw all cells including the numbers.
3. Activity
We start with developing the activty. The usage details will give a better feeling of what the new NumGridView view has to implement.
3.1. Activity code
Fpr the activity we use the normal pattern (red) of a member variable mNumGridView for a view being declared, and being looked up mNumGridView=(NumGridView)findViewById(R.id.numgridview), so that its listener can be set mNumGridView.setOnCellTouchListener(mNumGridView_OnCellTouchListener).
public class NumGridActivity extends Activity { NumGridView mNumGridView; OnCellTouchListener mNumGridView_OnCellTouchListener = new OnCellTouchListener() { @Override public void onCellTouch( NumGridView v, int x, int y ) { v.setCell(x, y, v.getCell(x,y)+1 ); } }; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); mNumGridView= (NumGridView)findViewById(R.id.numgridview); mNumGridView.setOnCellTouchListener(mNumGridView_OnCellTouchListener); } }
We also see (blue) the cell touch listener for the numerical grid view (mNumGridView_OnCellTouchListener) being defined and being set. Note that the listener has three arguments. The first (v) is the NumGridView being touched (in our case there is only one, so v will always equal mNumGridView), and the next two arguments are the coordinates of the cell being clicked. In the body of the listener, we see property access in action (green): a cell value is being read, incremented and written back.
3.2. Activity XML layout
The XML layout file for the activity is straigtforward on a high level, but there are some twists.
We would expect a layout with a TextView and NumGridView stacked in a LinearLayout. Something like the following file.
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="@string/hint" android:textSize="5mm" /> <NumGridView android:id="@+id/numgridview" android:layout_width="fill_parent" android:layout_height="fill_parent" /> </LinearLayout>
The first twist is that NumGridView must be fully qualified: nl.fampennings.numgrid.NumGridView in our case since the class NumGridView resides in package nl.fampennings.numgrid.
We will give the NumGridView attributes, for example cellCountX and cellCountY to specify the number of cells in horizontal respectively vertical direction. So we would expect the XML file the contain a line like android:cellCountX="5". However, the attribute cellCountX is not part of the android namespace. It is part of the nl.fampennings.numgrid namespace. So, second twist, we have to add a namespace directive.
The correct XML file is found below. The twists have been marked with a color. Note that the choice of the name for the name space (blue) is completely free. On the other hand, the URL part http://schemas.android.com/apk/res/ is mandatory.
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:numgrid="http://schemas.android.com/apk/res/nl.fampennings.numgrid" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="@string/hint" android:textSize="5mm" /> <nl.fampennings.numgrid.NumGridView android:id="@+id/numgridview" android:layout_width="fill_parent" android:layout_height="fill_parent" numgrid:cellCountX="5" numgrid:cellCountY="5" /> <!-- stretch is false by default --> </LinearLayout>
4. NumGridView
We now switch to the NumGridView class.
4.1. The attributes of NumGridView
We ended the previous chapter with the XML layout file, setting attributes of a NumGridView instance. Let's therefore start with defining the attrbutes for NumGridView.
We have chosen three attrbutes: cellCountX, cellCountY, and stretch. The former two specify the number of cells in horizontal respectively vertical direction. The latter specifies whether the cells can be rectangular (to fill the screen space the NumGridView is given) or whether they need to be square (stretch=false), in which case padding is added around the grid.
The attributes of a view are specified in an XML file in res/values.
The location of the attributes file.
As Bill points out, it is had to find a spec of what goes into the attrs.xml file. But as you can see, we specify that NumGridView has a boolean attributes named stretch, and integer attributes named cellCountX and cellCountY with a minimal value of 1.
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="NumGridView"> <attr name="stretch" format="boolean" /> <attr name="cellCountX" format="integer" min="1" /> <attr name="cellCountY" format="integer" min="1" /> </declare-styleable> </resources>
4.2. NumGridView class outline
The NumGridView class resides in its own file (NumGridView.java). The class extends View, which means it may override a couple of methods. It is very rare not to override onDraw, since this functon determines the looks of our new class. As we will see later, onMeasure is typically also overriden to tell the rest of the framework how big our view wants to be.
The class outline below also shows a constructor (we have been lazy, and only added the constructor needed by the XML inflator), and a getter and setter for the cell values. Finally, we need some code to implement the OnCellTouchListener.
public class NumGridView extends View { // Member variables ... public NumGridView (Context context, AttributeSet attrs) { ... } public void setCell(int x, int y, int v) { ... } public int getCell(int x, int y ) { ... } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { ... } @Override protected void onDraw (Canvas canvas) { ... } // Defintions around OnCellTouchListener ... }
These features will be described in following sections.
4.3. Constructor
A view normally has three constructors. We have been lazy; we only implemented the second constructor.
View(Context context) | |
Simple constructor to use when creating a view from code. | |
View(Context context, AttributeSet attrs) | |
Constructor that is called when inflating a view from XML. | |
View(Context context, AttributeSet attrs, int defStyle) | |
Perform inflation from XML and apply a class-specific base style. |
The constructor has four parts: initializing the base class, setup paint's, read XML attributes, and setting up the grid cells. We will discuss each part briefly.
public NumGridView(Context context, AttributeSet attrs) { // Init the base class super(context, attrs); // Setup paint background and foreground mPaintBg= new Paint(); mPaintBg.setAntiAlias(true); mPaintBg.setColor(0xFFFFFFFF); mPaintBg.setStyle(Paint.Style.FILL_AND_STROKE); mPaintFg= new Paint(); mPaintFg.setAntiAlias(true); mPaintFg.setColor(0xFF606060); mPaintFg.setTypeface(Typeface.create(Typeface.SERIF, Typeface.ITALIC)); mPaintFg.setTextAlign(Paint.Align.CENTER); // Get the XML attributes TypedArray a= context.obtainStyledAttributes(attrs, R.styleable.NumGridView); mStretch= a.getBoolean(R.styleable.NumGridView_stretch, false); mCellCountX= a.getInt(R.styleable.NumGridView_cellCountX, 8); mCellCountY= a.getInt(R.styleable.NumGridView_cellCountY, 8); a.recycle(); // Setup the grid cells mCells= new int[mCellCountX][mCellCountY]; for(int y=0; y<mCellCountY; y++) for(int x=0; x<mCellCountX; x++) mCells[x][y]= 0; }
First, the constructor calls the initializer of the base class. Note that this takes care of a lot of details, including parsing the standard XML attributes (id, layout_height).
Next, the constructor sets up two paint objects (mPaintBg and mPaintFg) which will be used to draw the background of each cell (grey rectange) respectively the foreground (number) in the cell. One thing is missing in the foreground settings: the font size. That will be calculated at a later stage, when the size of the cells is known.
The third part is still rather confusing to me. Its purpose is to get the attribute values from the layout xml file specific for our NumGridView. In other words, getting the red parts in the fragment shown below.
<nl.fampennings.numgrid.NumGridView android:id="@+id/numgridview" android:layout_width="fill_parent" android:layout_height="fill_parent" numgrid:cellCountX="5" numgrid:cellCountY="5" /> <!-- stretch is false by default -->
Let's try to disect the getting of the XML attributes, and concentrate on the first line of that part.
TypedArray a= context.obtainStyledAttributes(attrs, R.styleable.NumGridView);
My understanding is that the attrs parameter contains (an encoding of) the above XML fragment. The function obtainStyledAttributes "parses" the XML fragment and returns the attribute values. However, it does not return all attribute values, that would be too inefficient, a view already has more than 50 attributes, and most of them are already processed in the constructor of the base class. So, obtainStyledAttributes is passed a second argument, namely an array of attributes that we are interested in.
As happens in other android places, attribues are identified by some magic number. If we look in the generated file gen\nl.fampennings.numgrid\R.java (see fragment below), we find the identifying numbers for our attributes (which have been marked red). In other words, if we pass as second parameter to obtainStyledAttributes, the array {0x7f010000, 0x7f010001, 0x7f010002}, the function obtainStyledAttributes returns the values of the attributes stretch, cellCountX, and cellCountY.
If you look closely, you'll see that the second parameter in the call to obtainStyledAttributes is actually R.styleable.NumGridView. This constant resides in the same generated XML file (see blue part below), and its value happens to be precisely the just mentioned array {0x7f010000, 0x7f010001, 0x7f010002}!
public final class R { public static final class attr { public static final int stretch=0x7f010000; public static final int cellCountX=0x7f010001; public static final int cellCountY=0x7f010002; } public static final class styleable { public static final int[] NumGridView = {0x7f010000, 0x7f010001, 0x7f010002}; public static final int NumGridView_stretch = 0; public static final int NumGridView_cellCountX = 1; public static final int NumGridView_cellCountY = 2; }; }
So, the obtainStyledAttributes returns the values for stretch, cellCountX, and cellCountY, in that order. To be more precise, it returns an array of objects, where the first object is null (because there is no stretch attribute in the XML fragment), the second object is Integer(5) (because the XML fragment says cellCountX="5") and the third object is also Integer(5) (because the XML fragment says cellCountY="5") .
You cannot put an int (or other primitive value) into a data collection that holds object references. You have to box primitive values into the appropriate wrapper class (which is Integer in the case of int). When you take the object out of the data collection, you get the Integer that you put in; if you need the int, you must unbox the Integer first.
In other words, the TypedArray a that holds the result of obtainStyledAttributes has the following content: a[0]=null, a[1]=Integer(50), and a[2]=Integer(50). If we have a look at the green parts of the file gen\nl.fampennings.numgrid\R.java shown above, we see that we can rephrase this as follows: a[R.styleable.NumGridView_stretch]=null, a[R.styleable.NumGridView_cellCountX]=Integer(50), and a[R.styleable.NumGridView_cellCountY]=Integer(50).
Let's go back to disecting the third step of the constructor (getting the XML attributes). We have just seen what the typed array a now contains. The second part of the the third step contains three lines, each one unboxes an element of a. A default value is supplied in case the array element is null, since that signals the attribute was not specfied in the XML file (like stretch in our case).
Note that array a must be "recycled" when we're done with it. This concludes the discussion of getting the XML attributes.
The last step of the constructor sets up the cell values. That's rather easy now: we know how many cells there are cellCountX by cellCountY, so we create the two dimensional array and set all cell values to 0.
4.4. Cell value setter and getter
The previous section ended with creating and zeroing the array of cell values. This section discusses two functions: a setter and getter for the cell values.
public void setCell(int x, int y, int v) { if( ! (0<=x && x<mCellCountX) ) throw new IllegalArgumentException("setCell: x coordinate out of range"); if( ! (0<=y && y<mCellCountY) ) throw new IllegalArgumentException("setCell: y coordinate out of range"); mCells[x][y]= v; invalidate(); } public int getCell(int x, int y ) { if( ! (0<=x && x<mCellCountX) ) throw new IllegalArgumentException("getCell: x coordinate out of range"); if( ! (0<=y && y<mCellCountY) ) throw new IllegalArgumentException("getCell: y coordinate out of range"); return mCells[x][y]; }
I've decided to guard the array access with checks that generate an exception. The second thing worth noting is the call to invalidate() (red). This tells the view to redraw itself, that is, onDraw will be called (probably by putting a draw message in the thread queue).
4.5. onMeasure
When an activity wants to render itself, it traverses the view tree to find out how big each view wants to be. In practice, it traverses the view tree multiple times; the first sweep investigates the native size of each view, and the next sweep puts constrains on each view (we have only so many pixels to spend).
In these sweeps, the activity calls onMeasure, asking the view for its native size, possibly imposing a maximum size. The result of calling this function is not conveyed through a return value, rather the function setMeasuredDimension should be called.
Our view must play along. There is a default imlementation of onMeasure that calls setMeasuredDimension. If we decide to overwrite the onMeasure function, we must make sure to call setMeasuredDimension.
It's a feature of our grid to maximize itself in the remaining area, giving all cells the same size. It's even a feature to have all cells square. So yes, we override onMeasure.
void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
A peculiarity of the function is the parameter passing. There is a so-called measure spec for the horizontal direction ("width") and one for the vertical direction ("height"). A measure spec consists of two parts: a mode and a size. For efficiency reasons, they are not placed in an object with two fields (mode and size), rather thay are packed into an integer. So the first thing we do is unpack both measure specs.
int widthMsMode = MeasureSpec.getMode(widthMeasureSpec); int widthMsSize = MeasureSpec.getSize(widthMeasureSpec); int heightMsMode = MeasureSpec.getMode(heightMeasureSpec); int heightMsSize = MeasureSpec.getSize(heightMeasureSpec);
The mode can have three different values.
-
UNSPECIFIED
The parent has not imposed any constraint on the child. It can be whatever size it wants (known as default or native size). -
EXACTLY
The parent has determined an exact size for the child. The child is going to be given those bounds regardless of how big it wants to be. -
AT_MOST
The child can be as large as it wants up to the specified size.
So, the next step is use the mode to determine a view size. In the "unspecified" case we use a default (native) size of 32×32 for each cell. In the "exactly" and "at most" case, we use the passed size.
int defaultSizeX= 32; int defaultSizeY= 32; // Determine view width and height: either default size or passed size int vw= ( widthMsMode ==MeasureSpec.UNSPECIFIED ) ? mCellCountX*defaultSizeX : widthMsSize; int vh= ( heightMsMode==MeasureSpec.UNSPECIFIED ) ? mCellCountY*defaultSizeY : heightMsSize;
From the view size we compute the cell size (width and height). It's a fractional number for now, so typically it must later be floor'ed to get to real pixels. Furthermore, we ignore stretching for now.
// Determine cell width and height, assuming stretch is allowed double cw= vw / mCellCountX; double ch= vh / mCellCountY;
Next, we do take stretching into account. If it is allowed to stretch, we can just floor the cell width and height independently. If stretch is not allowed, we pick the smallest size (of width and height) and use that for both directions.
// Determine cell width and height adhering to stretch attribute if( mStretch ) { mCellWidth = (int)Math.floor(cw); mCellHeight= (int)Math.floor(ch); } else { double size= Math.min(cw,ch); mCellWidth = (int)Math.floor(size); mCellHeight= (int)Math.floor(size); }
Now that we floor'ed the cell sizes, the whole grid should be shifted a little bit to center it again.
// Determine offset mOffsetX= ( vw - mCellWidth *mCellCountX ) / 2; mOffsetY= ( vh - mCellHeight*mCellCountY ) / 2;
Finally, we satisfy the contract by calling setMeasuredDimension. The one-liner looks impressive, but it's just offset on both sides and count × width (height) for the size of all cells.
// Satisfy contract by calling setMeasuredDimension setMeasuredDimension( mOffsetX + mCellCountX*mCellWidth + mOffsetX , mOffsetY + mCellCountY*mCellHeight + mOffsetY );
There's one last hack, now that we know how high each cell is, we set the font size for the cell values (better code will be presented later).
mPaintFg.setTextSize( mCellHeight*0.8f );
Then we close the function body.
}
4.6. onDraw
The onDraw function is called when a view needs to draw itself (e.g. when an activity becomes visible). The function has a very important parameter, namely the canvas on which the view should draw itself.
void onDraw( Canvas canvas ) { ... }
The onDraw method will loop over all cells. For each cell, it will draw a filled rectangle (drawRect), and the number (drawText). For the former it will use the mPaintBg and for the latter mPaintFg.
Drawing the rectangles is the easy part: there is a stock method for that and we know all dimensions (cell width and height and grid offset). Note that we draw the rectangles 1 pixel smaller on each side (red), so that we get a border.
... for( int y=0; y<mCellCountY; y++ ) { for( int x=0; x<mCellCountX; x++ ) { // Draw a rectangle int dx= x*mCellWidth+mOffsetX; int dy= y*mCellHeight+mOffsetY; canvas.drawRect( new Rect(dx+1,dy+1,dx+mCellWidth-2,dy+mCellHeight-2), mPaintBg ); // Draw the cell value ... } }
Drawing the text is harder. First of all we have to understand the key font paremeters. The figure below illustrates them. One thing to note is that ascent is negative and descent is a positive number.
The ascent and descent parameters of a font. Note that ascent is a negative value.
We will center the text in the cell. The horizontal centering is delt with in the paint: mPaintFg.setTextAlign(Paint.Align.CENTER). For the vertical centering, we compute the position of the baseline. This is illustrated in the following figure.
Positioning the baseline: down half the cell height, then down half the fontsize, then up the descent.
The position of the baseline within the cell is determined as follows. First we go down by half the cell height (mCellHeight). Next, we go down by half the font height (sum of ascent and descent). Finally, we need to go up by the descent. Since ascent happens to be negative we get the following code to determine the text origin tx, ty.
// Compute text origin inside the cell float fontsize= mPaintFg.descent()-mPaintFg.ascent(); int tx= (int) ( mCellWidth/2 ); int ty= (int) ( mCellHeight/2 + fontsize/2 - mPaintFg.descent() );
Drawing the text then boils down to the following code. The ""+v converts the integer v to a string.
// Draw all cells for( int y=0; y<mCellCountY; y++ ) { for( int x=0; x<mCellCountX; x++ ) { // Draw a rectangle int dx= x*mCellWidth+mOffsetX; int dy= y*mCellHeight+mOffsetY; ... // Draw the cell value int v= mCells[x][y]; canvas.drawText( ""+v, dx+tx, dy+ty, mPaintFg ); } }
The text size of mPaintFg is computed in onMeasure. The code was presented in the previous section. Here we present a more robust algorithm. We compute the factor between the specified font size and the font size we measure. We use that font factor to set a new text size which is 80% of the cell height.
// Set font size float specified_fontsize=mPaintFg.getTextSize(); float measured_fontsize= mPaintFg.descent()-mPaintFg.ascent(); float font_factor= specified_fontsize/measured_fontsize; mPaintFg.setTextSize( mCellHeight*0.8f*font_factor );
4.7. OnCellTouchListener
The last thing we want to add to our class is a "callback" when a cell is touched. We wil use the standard aproach of defining and interface, to be implemented by some application object called a listener, that becomes active by set-ting (registering) it at our view. Our view should then callback the listener, when the touch happens.
But what is the standard approach? Let's open the View class (he, android is open source!), and have a look at a callback that comes close: the touch listener.
class View { ... lot's of code omited ... public interface OnTouchListener { boolean onTouch(View v, MotionEvent event); } private OnTouchListener mOnTouchListener; public void setOnTouchListener(OnTouchListener l) { mOnTouchListener = l; } public boolean dispatchTouchEvent(MotionEvent event) { // Slightly simplified for the explanation if( mOnTouchListener!=null && mOnTouchListener.onTouch(this, event) ) { return true; } return onTouchEvent(event); }
Let's start replicating that for the cell touch listener in our class.
public interface OnCellTouchListener { void onCellTouch( NumGridView v, int x, int y ); }
The above interface definition resides in the NumGridView class. It contains a single function (onCellTouch) passing the x and y coordinate of the cell being touched. It is good practice to also pass the "sender" ("source", "originator") of the callback; this is the v parameter in the onCellTouch function. Passing a sender allows the application to write a single listener for multiple NumGridView's, where the listener can still distinguish between the senders.
The NumGridView class is augmented with a member variable of this new interface type.
protected OnCellTouchListener mOnCellTouchListener;
Of course, we need a setter for this property. In the tradition of android, we do not implement a getter.
public void setOnCellTouchListener(OnCellTouchListener listener) { mOnCellTouchListener = listener; }
Now comes the hard part. The NumGridView should call the listener (through mOnCellTouchListener). When? The cell touch listener of NumGridView is a specialisation of the touch listener of View. A tempting approach would be tap the touch listener of the base class. The code would be something like the following.
class NumGridView implements OnTouchListener { @Override boolean onTouch(View v, MotionEvent event) { // call mOnCellTouchListener.onCellTouch(v,x,y) } public NumGridView(..) { setOnTouchListener(this); } }
In the approach, the NumGridView class implements a touch listener (it promisses this in red, and actually does this in blue). In its constructor, it registers itself (green) as a listener of the touch events of itself (namely its base class).
This approach has one serious downside. Users of NumGridView can no longer register a listener for plain touch events (for example, if they are interested in "raw" touches to distinguish where in a cell the touch was). Actually it's worse. Since the NumGridView class is an extension of the View class, user know and can actually register a listener for a view event. But by doing so, they break the cell touch listener functionality.
There is a easy way out. And it was presented at the start of this section! The android View class has a single place where the touch handler is called, namely in function dispatchTouchEvent. So if we override that function we can call our cell touch listener, then call the super class, which handles the plain touch listeners.
@Override public boolean dispatchTouchEvent(MotionEvent event) { // First dispatch calls to our cell touch listener... if( mOnCellTouchListener!=null ) { int x=(int)(event.getX()) - mOffsetX; int y=(int)(event.getY()) - mOffsetY; if( 0<=x && x<mCellWidth*mCellCountX && 0<=y && y<mCellHeight*mCellCountY ) { // Touch was on cell (not on padding area) mOnCellTouchListener.onCellTouch( this, x/mCellWidth, y/mCellHeight ); } } // ... next dispatch calls from the super class return super.dispatchTouchEvent(event); }
5. Looking back
It has proven to be feasible developing ones own view. Grasping all details of onMeasure is a bit too much for the first view, but writing onDraw was certainly not the hardest part. The cell touch listener didn't have any surprises.
It is a pity that the xml file still requires some name jugling.
Download the source of this article.