Getting started with views

21 November 2010

I wanted to get somewhere fast with with Android's 2d libraries so I leapt right in with some some JavaDoc trawling and some learning-by-doing. I've written up my couple of hours of experimentation below.

Topics covered:

  • Declarative layouts
  • Drawing on a Canvas
  • Subclassing View and ViewGroup
  • Customising a view's the measurements and positioning

Setting a goal

In a previous project, I made a simple web service which allowed "check-ins" to record progress and would produce data representing a graph of check-ins per period. I thought that hooking this service up to an android app might be a good medium-term project. As a tiny first step I thought I'd try and produce an on-screen graph. This gave me a small, achievable goal to hack towards.

Setting up the Application

Priority one in a little project like this should be to get something basic up and running. I wasn't trying to do anything more than a quick 'spike' here, so my 'tests' were more like "Does it compile, and does it look right?". I've been doing a lot of TDD recently, but it didn't feel appropriate to write tests when my purpose is purely to explore the API.

I had some rough thoughts on how the interface should look and behave, so I made a few early decisions.

I decided to start with a simple, static, bar-graph consisting of a background, grid-lines, and the representation of the data itself. I though it better not to put all the numbers on screen immediately, but perhaps if the user touched a bar they should get more information.

First step was to set up the Activities I would use in this example, a MainMenu and a ChartActivity to host the view.

@Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); // hook up the single button to the onClick callback findViewById(R.id.main_graph_button).setOnClickListener(this); } /** * When the button is clicked, start a ChartActivity */ public void onClick(View v) { switch(v.getId()) { case R.id.main_graph_button: Intent goToChart = new Intent(this, ChartActivity.class); startActivity(goToChart); break; } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); chart = new Chart(NUM_POINTS); populateChartWithDummyData(chart); initView(); } /** * I only need dummy data at this point, so create random * chart dataPoints. * @param chart */ protected void populateChartWithDummyData(Chart chart) { Random random = new Random(); for (int i = 1; i <= NUM_POINTS; i++) { chart.add(random.nextInt(1000), "data point " + i, null, null); } } /** * Initialize the view */ private void initView() { setContentView(R.layout.chart); }

Note that in ChartActivity I have references to Chart and DataPoint classes, both simple abstractions of their namesakes to hold data, not to display anything on the screen. In MVC terms, they are the model, and that makes ChartActivity the controller.

First Steps

This was my first layout. TextView placeholders, differentiated by colour.

<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_height="fill_parent" android:layout_width="fill_parent"> <TextView android:id="@+id/chart" android:layout_width="fill_parent" android:layout_height="30dip" android:text="@string/chart_view_title" android:background="@color/chart_background" /> <TextView android:id="@+id/chart_detail" android:layout_width="fill_parent" android:layout_height="30dip" android:text="@string/detail_view_title" android:layout_marginBottom="5dip" android:background="@color/detail_background" /> </LinearLayout>

Running the above in an emulator produced on-screen output in the emulator. Instant gratification!

Basic Layout

I wanted to make the graph View object fill up the empty space on the screen without explicitly setting its height. I soon discovered an attribute which would do the trick: adding android:layout_weight="1" to the chart TextView gave me this:

Basic Layout 2

And then it was time to move past placeholder text to graphics.

Custom Views

Development feels best to me when it's a progression of little wins, so I picked an easy next step: draw a simple background. I knew that the right place to start was to subclass android.view.View and override its onDraw() method.

@Override protected void onDraw(Canvas canvas) { // draw the actual background rect canvas.drawRect(0, 0, width, height, backgroundPaint); // draw grid lines on top int gridIncrement = height / chart.getNumGridlines(); float currentY = 0; for (int i = 0; i < chart.getNumGridlines(); i++) { canvas.drawLine(0, currentY, width, currentY, gridLinePaint); currentY += gridIncrement; } }

There's nothing fancy going on in the drawing code here. The Canvas object passed into onDraw() provides methods to output lines and rectangles etc in simple coordinate space.

To get this showing on screen, I had to incorporate my custom view class into a declarative layout. It didn't take long to discover that I could reference any class on the classpath in your layout XML; I just had to specify the fully qualified class name:

<info.ragtag.starchart.view.ChartBackground android:layout_height="fill_parent" android:layout_width="fill_parent"/>

But after I'd referenced it in this way, the app threw exceptions when I tried to load the chart page. Turned out that View has two constructors, View(Context context) and View(Context context, AttributeSet attributeSet). The second constructor is called when a View is instantiated from an XML layout file, with the AttributeSet object containing the 'layout_height', 'layout_width' and other attributes that have been defined. I had only overridden the first of these constructors, so overriding the second made everything work again.

The last roadblock was working out how to get a reference to the ChartActivity in the View class. The View had to know about the Chart object set up by the ChartActivity so that it knew how many gridlines to draw. I tried three or four different approaches until I finally realized that the Context object passed to the view's constructor is the activity which has set up the view (android.app.Activity is a subclass of android.content.Context). So I just had to save the reference in the constructor, and have ChartBackground ask the activity for a reference to the Chart.

Putting bars on the bar chart

To make the bar's an interactive component of the interface, I decided to create a View object for each individual bar. I spotted a class called ViewGroup in the JavaDoc, and read that Views end up in a hierarchy. It seemed that a bunch of Views and a ViewGroup would be exactly what I needed.

The LinearLayout class is a ViewGroup, and also one of the easiest layout classes to use, so I thought it would make an ideal home for my bars. I needed to get it displaying in the same space as the ChartBackground, so I ended up with a hierarchy as follows:

LinearLayout (top-level layout) `->FrameLayout (holds the whole graph) `->ChartBackground `->LinearLayout (to hold the bars) `->TextView (details)

In ChartActivity, I grabbed the LinearLayout, and manually added a ChartBar for each data point:

/** * Initialize and compose the view objects which will compose * the UI. */ private void initView() { setContentView(R.layout.chart); ChartBarGroup barGroup = (ChartBarGroup) findViewById(R.id.chart_bars); for (DataPoint dataPoint : chart.getDataPoints()) { View bar = new ChartBar(this, dataPoint); barGroup.addView(bar); } }

And this is what I saw:

One Large Bar

Hmm, what's going on here?

Parental Responsibilities

With a bit of logcat action and some fiddling, I worked out why I was seeing what I was seeing. Without any additional instructions, the bars were defaulting to a height and width of "fill_parent". When the GUI thread came to draw the bars, it would take them one by one, independently. The first one to get drawn would grab all the available space, and all the others ended up with a width of 0.

So, I clearly needed to be more explicit in telling the bars how big they should be. I began by looking at callbacks I could override in View. There were clearly some that dealt with measurements.

I started with onSizeChanged(), taking the width passed and reducing it to see whether I could persuade the bar to be a bit less greedy for width. But this had no effect. It seemed as if onSizeChanged() was being called too late in the process of to affect the final width. You can use it to react to changes, but not to influence those changes.

I then noticed a method called getParent(). I thought that if I could get a reference to the layout that contained the bars, I could find out its measurements and then set measurements on the ChartBar accordingly. Alas, this is decidedly not the way the API is supposed to be used. getParent() returns a ViewParent object, not a View. This class defines the information a child should be able to find out about its parent, and that does not include size data.

Next I looked at onLayout() and onMeasure(). Nothing that I did in here had any effect either. By going back to the JavaDoc, I saw that these methods are designed for parents to draw their children.

And this is when it dawned on me that I was doing things the wrong way round. In the Android API, children should obey their parents without showing too much initiative. The action of setting heights and widths is something that should be done by a parent, to a child.

This led me to subclass LinearLayout, creating ChartBarGroup. I let LinearLayout do almost all the work, but I overrode onSizeChanged():

/** * When this group's size is changed, use the values to calculate * each individual bar's proper size. */ @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { int displayWidth = w - getPaddingLeft() - getPaddingRight(); int chartBarWidth = displayWidth / getChildCount(); for (int i = 0; i < getChildCount(); i++) { View chartBar = getChildAt(i); chartBar.getLayoutParams().width = chartBarWidth; chartBar.getLayoutParams().height = h; chartBar.invalidate(); } this.invalidate(); super.onSizeChanged(w, h, oldw, oldh); }

And it worked perfectly. Note that I call 'invalidate' each View after I set a new size to tell the GUI thread to redraw it when it can.

Final Layout

Making the bars touchable

One final step was to add a tiny bit of interactivity. When the user touched a bar, I wanted bar's value to show up in the TextView at the foot of the screen. This was simple: I just overrode onTouchEvent() in ChartBar:

/** * Respond when this bar is touched. */ @Override public boolean onTouchEvent(MotionEvent event) { // only deal with simple taps: if (event.getAction() != MotionEvent.ACTION_DOWN) return super.onTouchEvent(event); // report the event back to the Activity chartActivity.reportBarTouch(dataPoint); return true; }

ChartActivity can use the dataPoint to get the bar's value and decide what to put in the TextView.

/** * Bars on the chart can report back if they are tapped * by the user, passing their data. * * @param dataPoint */ public void reportBarTouch(DataPoint dataPoint) { TextView detailsText = (TextView) findViewById(R.id.bar_details); detailsText.setText(dataPoint.getLabel() + ": " + dataPoint.getValue()); }

And with that, I declared this particular experiment over. If you've read this far, I hope you got a bit of useful information out of this piece. I will try and carry on posting up my progress with Android.

Related tags: starchart, view, viewgroup

Comments are closed.

Comments have been closed for this post.