Saturday, April 14, 2012

Android Multi-Column List with Static Header

Figure 1 Screenshot

Introduction

This tutorial was written to demonstrate how to work with a list of data using a ListView with Android. In particular, I want to show how to display a fixed header with a ListView where the header area is stationary and does not scroll with the contents of the list. I also want to dynamically set the text strings in the header area. This is to cover a situation where the header information is not known until the activity is launched. Another objective is to show how to display the data in a multi-column list where the data and the column headings are nicely aligned. Finally, I want to show how to populate the ListView with rows of data.

All code listings are available from my github project.

MyStringPair Class

The data that will be displayed in the list are objects instantiated from a simple class, MyStringPair.javaThis is a simple class consisting of two String type member variables, getter and setter methods, and a static method for conveniently generating a list of MyStringPair objects.
public class MyStringPair {

    private String columnOne;
    private String columnTwo;
    // ... Skipping code for brevity


The Layout

Figure 1 shows the visual effect that I want to achieve. There is a list header with two lines of description and a third line for the column headings. Then there is the list of data. The column headings align with the columns of data. The header area is fixed and does not scroll with the list.

To accomplish the look in Figure 1, I use a layout resource file (main.xml) with a vertical LinearLayout as the root element. It has four child elements:
  • A TextView element that contains the first header line
  • Another TextView element for the second header line
  • A LinearLayout element that contains the two TextView column headings which are arranged horizontally.
  • The ListView element that contains the contents of the list.

Listing 1. main.xml.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    >
    <!--  First header line -->
    <TextView android:id="@+id/header_line1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textColor="#f00"
        />
    <!-- Second header line -->
    <TextView android:id="@+id/header_line2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textColor="#0f0"
        />
    <!-- Third header line and column headings -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:background="#006"
     >
        <TextView android:id="@+id/column_header1"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            />
        <TextView android:id="@+id/column_header2"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            />
    </LinearLayout>
  
    <ListView
        android:id="@+id/listview"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        />
</LinearLayout>

Things to note in the main.xml resource file, Listing 1:
  1. Root LinearLayout element sets the android:layout_width and  android:layout_height properties to match_parent. This will make this view use all available display space. 
  2. The three header line elements (the two TextView's and the LinearLayout) use match_parent as the  android:layout_width property so that the total display width is used.
  3. The three header line elements set the values of android:layout_height property to wrap_content. This means that the height of the TextView elements in the first and second header lines will be just large enough to contain the text and padding. The same is true for the LinearLayout element of the third header line. The Android docs talk about the wrap_content value here.
  4. The LinearLayout of the third header line contains two TextView elements which flow horizontally. This is done by setting android:orientation to horizontal.
  5. The two TextView column header elements for the column headings in the third line are contained within a horizontal LinearLayout element. The value wrap_content is used for the layout_height property of the parent element, but the width is set using  android:layout_width="0dp". Another property,  android:layout_weight, also used. More on this later in the Column Alignment section.
  6. None of the TextView elements in the resource file use the android:text property to set the text in the header area. Since the actual values are not known until the activity starts, they will be set in the activity code using the onCreate() method. This is discussed in next section.
Note: Sometimes you will see fill_parent used rather than match_parent. As of API level 8, fill_parent has been deprecated and the use of match_parent is preferred.

Dynamically Setting the Text in the Header Area

Dynamically setting a text property in the code is simply done by passing the text string with the setText() method of the TextView object. To do this, first get a reference to the TextView object by calling findViewById() with the resource id of the TextView. The resource id is defined by the android:id property of the element in the resource file. For example, to get the reference to the first header line, use: 

TextView description1 = (TextView) findViewById(id.header_line1);

The findViewById() method will start from the root element and recursively search the view hierarchy to find the element with the resource id of header_line1 and return the TextView. Android does not require that the resource id to be unique, but it's a good idea to make them unique within the context of the view hierarchy from which the search is made.

The column headings are set in the same manner. These are the TextView's are contained within a horizontally oriented LinearLayout (See main.xml, Listing 1). Listing 2 shows how all the text in the header area are set in the code.

Listing 2. Code fragment that gets references to the TextView objects and sets the text properties. From HeaderdemoActivity.java.
TextView description1 = (TextView) findViewById(id.header_line1);
TextView description2 = (TextView) findViewById(id.header_line2);
description1.setText("This is the first line describing the list");
description2.setText("Another description in the header");

TextView columnHeader1 = (TextView) findViewById(R.id.column_header1);
TextView columnHeader2 = (TextView) findViewById(R.id.column_header2);

columnHeader1.setText("Sequence");
columnHeader2.setText("Precipitation (inches)");

Things to note in HeaderDemoActivity.java, Listing 2:

  1. Use findViewById() to recursively search the view hierarchy to find the resource id of the TextView. It starts the search in the current context.
  2. Once we have the resource id of the TextView object, use setText() to update the value of the string.


The ListView

In Figure 1, the list content is displayed in rows. Each row consists of a LinearLayout containing two TextView elements that are oriented horizontally (See Listing 3).

By setting the android:layout_width property to match_parent, the width will inherit from the parent view, which is also the root view which gets its width from match_parent. The effect will be that the ListView will use the complete display width of the device.

Listing 3. Resource layout for each row in the ListView.From listrow.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal" >
    
    <TextView android:id="@+id/column1"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        />
    
    <TextView android:id="@+id/column2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        />

</LinearLayout>

Things to note in listrow.xml, Listing 3.

  1. The container element is LinearLayout and it's orientation is horizontal causing it's child TextView elements appear side by side.
  2. The match_parent property allows the LinearLayout element to use the total available width.
  3. The wrap_content property of the LinearLayout element allows each row in the list to be just tall enough to contain its children plus any padding.
  4. The android:id properties of the TextView elements are required in order to set the text strings of the TextView objects within the adapter code (See Listing 4, MyStringPairAdapter.java). .
  5. Each TextView element sets android:layout_width to "0dp" and android:layout_weight to "1". The Column Alignment section will discuss this in more detail.
  6. The android:layout_height property is set to wrap_content for each TextView.
According to the article, Layout Tricks: Creating Efficient Layouts, the LinearLayout may not be very efficient when complex nesting is used for lists. For large lists with nested views, the article recommends using a relative layout instead. In this case the TextView views nested directly under the LinearLayout so I don't think a RelativeLayout is warranted.

Column Alignment

The two column headings should align precisely with the columns of data. From a layout perspective, the element hierarchy that begins at the column heading is the same as the list row. That is, both are defined by a LinearLayout which directly contains two TextView elements that are arranged horizontally. Please compare the second LinearLayout element and its child TextView elements in main.xml of Listing 1 with those in listrow.xml of Listing 3. Except for the android:id properties of the TextView elements, all properties are the same. Both layouts can be treated in the same manner by using the same properties and values for the respective LinearLayout and TextView elements.

As previously mentioned, the width property of the LinearLayout element is set to match_parent and will extend the full width of the parent container which will be the width of the display. The height property is set to wrap_content and will just be tall enough to contain the text and any padding that is required. It is interesting to note the use of the android:layout_width and android:layout_weight properties for the two TextView elements. Each android:layout_weight property is set to "1" to cause each TextView element to consume the same amount of horizontal space. It seems that the android:layout_width must be set to something and the docs recommend that layout_width to "0dp" for performance reasons. More on this here. The result is that the data columns align exactly with the column headings.

Things to note about column alignment:
  1. The container LinearLayout element uses horizontal orientation and android:layout_width as match_parent.
  2. The TextView elements use android:layout_width="0dp"
  3. Each of the two TextView elements uses android:layout_weight="1" so that they use equal amounts of the available width.
  4. The android:layout_width property of each TextView element is set to "0dp".   

Create an Adapter to Populate the ListView

In this application, each row represents a Java object that is instantiated from MyStringPair class (not shown here; for code, please see MyStringPair.java in my github project). MyStringPair class consists of two String type member variables, getter and setter methods, and a static method for conveniently creating a list of MyStringPair objects.

One way to display the list of objects in the ListView is to create an adapter class, called MyStringPairAdapter (Listing 4) that extends the android.widget.BaseAdapter abstract class. This adapter will map the objects from the list to the ListView. See the github repository for the complete listing.

Listing 4. MyStringPairAdapter class.

public class MyStringPairAdapter extends BaseAdapter {
    private Activity activity;
    private List stringPairList;
    

    public MyStringPairAdapter(Activity activity, List stringPairList) {
        super();
        this.activity = activity;
        this.stringPairList = stringPairList;
    }

    @Override
    public int getCount() {
        return stringPairList.size();
    }

    @Override
    public Object getItem(int position) {
        return stringPairList.get(position);
    }

    @Override
    public long getItemId(int position) {
        // TODO Auto-generated method stub
        return 0;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        
        if (convertView == null) {
            LayoutInflater inflater = activity.getLayoutInflater();
            convertView = inflater.inflate(R.layout.listrow, null);
        }
        TextView col1 = (TextView) convertView.findViewById(R.id.column1);
        TextView col2 = (TextView) convertView.findViewById(R.id.column2);
        
        col1.setText(stringPairList.get(position).getColumnOne());
        col2.setText(stringPairList.get(position).getColumnTwo());
        
        return convertView;
    }
}

The main things to consider in the MyStringPairAdapter class are:

  1. MyStringPairAdapter contains only two member variables: the activity containing the view and a list of objects that will be displayed. Both are set in this adapter using the constructor.
  2. The getView() method is overridden and provides the code that will set the text values in the TextView elements.
  3. The getCount() method is overridden so that it returns the size of the list. I think this has something to do with the ListView scrolling.
  4. The getItem() method is overridden to return the object with index postion to the ListView.
  5. There was no need to override the getItemId() method since the application does not do any selecting.
  6. The view needs to be inflated. If this is a new view, convertView will be null and the LayoutInflater's inflate() method needs to be called.
  7. ConvertView will contain the two TextView objects in its view hierarchy. Call findViewById() to get the TextView objects.
  8. The TextView objects are set using the setText() method;

Complete the ListView Setup

The final task for getting the ListView to work is to instantiate the adapter, MyStringPairAdapter and pass it to the ListView using the setAdapter() method. Listing 5 demonstrates this.

Listing 5. Code fragment from HeaderDemoActivity.java showing the creation of the ListView.

ListView view = (ListView) findViewById(R.id.listview);
final List<MyStringPair> myStringPairList = MyStringPair.makeData(10);
MyStringPairAdapter adapter = new MyStringPairAdapter(this, myStringPairList);
      
view.setAdapter(adapter);

Things to note in HeaderDemoActivity.java, Listing 5:
  1. Use findViewById() to get the object reference to the ListView.
  2. Instantiate the adapter, passing the list of MyStringPair objects.
  3. Set the view's adapter using the setAdapter() method.


Complete code listings and a working Android project can be downloaded from my github repository.