Android 4
7. Advanced ListView Widget - Part A
This chapter
- 7.0 Advanced ListView
- 7.1 ListView with One Icon
- 7.2 ListView with Two Icons
- 7.3 Efficient ListView with Two Icons
- 7.4 ListView with RatingBar
- 7.5 Custom ListView - 3 subrows
- 7.6 Custom ListView with 3 Columns
- 7.7 Custom ListView with Collapsing Rows
- 7.8 Expandable ListView
In this chapter, we will have a list whose rows are made up of icons, checkboxes, and some other variations.
It's just a matter of supplying data to the adapter and helping the adapter to create a richer set of View objects for each row. We'll also have examples of efficient ListView using recycling or holder pattern. Then, we'll deal with some dynamic ListView combined with RatingBar.
Our main layout, main.xml:
<?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:id="@+id/selection" android:layout_width="fill_parent" android:layout_height="wrap_content"/> <ListView android:id="@android:id/list" android:layout_width="fill_parent" android:layout_height="fill_parent" android:drawSelectorOnTop="false"/> </LinearLayout>
If we want a ListView whose entries are made up of an icon followed by some text, our custom layout for the row, row.xml will be like this.
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android= "http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <ImageView android:contentDescription="@string/ok_icon" android:id="@+id/icon" android:layout_width="16dp" android:paddingLeft="2dp" android:paddingRight="2dp" android:paddingTop="2dp" android:layout_height="wrap_content" android:src="@drawable/ok"/> <TextView android:id="@+id/label" android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingTop="10dp" android:textSize="24sp"/> </LinearLayout>
This layout uses a LinearLayout to set up a row, with the icon one the left and the text on the right. But, by default, Android has no idea about our intention of using this layout with our ListView. To make the connection between the layout and the view, we need to supply our Adapter with resource ID of our custom layout.
So, our Java code:
package com.bogotobogo.ListViewStatic; import android.os.Bundle; import android.app.ListActivity; import android.view.View; import android.widget.ArrayAdapter; import android.widget.ListView; import android.widget.TextView; public class ListViewStaticActivity extends ListActivity { TextView selection; @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); setContentView(R.layout.main); setListAdapter(new ArrayAdapter<String>(this, R.layout.row, R.id.label, SmartPhones)); selection=(TextView)findViewById(R.id.selection); } public void onListItemClick(ListView parent, View v, int position, long id) { selection.setText(SmartPhones[position]); } static final String[] SmartPhones = new String[] { "HTC Rezound", "Samsung Galaxy S II Skyrocket", "Samsung Galaxy Nexus", "Motorola Droid Razr", "Samsung Galaxy S", "Samsung Epic Touch 4G", "iPhone 4S", "HTC Titan" }; }
Note that we use R.layout.row as argument for ArrayAdapter to tell it we want to use our custom layout.
The result is a ListView with icons down the left side.
The strings.xml should look like this:
<?xml version="1.0" encoding="utf-8"?> <resources> <string name="hello">Hello World, ListViewStaticActivity!</string> <string name="app_name">ListViewStatic</string> <string name="ok_icon">ok_icon</string> </resources>
Here are the images for ok.png (checkmark) used in this section
and for radio.png which will be used in the next section.
In the previous example, we supplied an alternate layout to use for rows. This handles simple cases, however, it may not work nicely for more complicated cases listed below:
For those cases, the better option is to create our own subclass of our own Adapter, override getView(), and construct our rows. The getView() method is responsible for returning a View, representing the row for the supplied position in the adapter data.
Let's work on a specific case. We're going to remake the code of the previous example to use getView(), so we can show different icons for rows. We'll use one icon for Samsung phones (checkmark icon) and one for other phones (radio icon).
Here is the Java code:
package com.bogotobogo.ListViewDynamic; import android.os.Bundle; import android.app.ListActivity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.ImageView; import android.widget.ListView; import android.widget.TextView; public class ListViewDynamicActivity extends ListActivity { TextView selection; /** Called when the activity is first created. */ @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); setContentView(R.layout.main); setListAdapter(new IconicAdapter()); selection=(TextView)findViewById(R.id.selection); } public void onListItemClick(ListView parent, View v, int position, long id) { selection.setText(SmartPhones[position]); } class IconicAdapter extends ArrayAdapter<String> { IconicAdapter() { super(ListViewDynamicActivity.this, R.layout.row, SmartPhones); } public View getView(int position, View convertView, ViewGroup parent) { LayoutInflater inflater=getLayoutInflater(); View row=inflater.inflate(R.layout.row, parent, false); TextView label=(TextView)row.findViewById(R.id.label); label.setText(SmartPhones[position]); ImageView icon=(ImageView)row.findViewById(R.id.icon); if (SmartPhones[position].startsWith("Samsung")) { icon.setImageResource(R.drawable.ok); } else { icon.setImageResource(R.drawable.radio); } return(row); } } static final String[] SmartPhones = new String[] { "HTC Rezound", "Samsung Galaxy S II Skyrocket", "Samsung Galaxy Nexus", "Motorola Droid Razr", "Samsung Galaxy S", "Samsung Epic Touch 4G", "iPhone 4S", "HTC Titan" }; }
We override getView() and return rows based on which object is being displayed, where the object is indicated by a position index into the Adapter.
public View getView(int position, View convertView, ViewGroup parent) { LayoutInflater inflater=getLayoutInflater(); View row=inflater.inflate(R.layout.row, parent, false); TextView label=(TextView)row.findViewById(R.id.label); label.setText(SmartPhones[position]); ImageView icon=(ImageView)row.findViewById(R.id.icon); if (SmartPhones[position].startsWith("Samsung")) { icon.setImageResource(R.drawable.ok); } else { icon.setImageResource(R.drawable.radio); } return(row); }
What is LayoutInflater class?
Inflation is converting an XML layout specification into the actual tree of View objects the XML represents: Taking an element, creating an instance of the specified View class, walking through attributes, converting those into property setter calls, iterating over all child elements, and so on. All of these works are wrapped up into the LayoutInflater class.
So, in our case, we want to inflate a View for each row shown in the list so that we can use the convenient shorthand of the XML layout to describe what the rows are supposed to look like.
We inflate the R.layout.row layout we created. This gives us a View object, which is our LinearLayout with an ImageView and a TextView. The XML and LayoutInflater will create all those objects and wire them together for us.
So, we have used LayoutInflater to give us a View represents the row. This row is "empty" since the static layout file has no idea what actual data goes into the row. It is our task to customize and populate the row as we see fit before returning it:
- Fill in the text label for our label widget, using the word at the supplied position.
- See if the word is using a specific word, here in the example,'Samsung', if so, find out ImageView icon widget and replace the stock resource with a different one.
XML files used in this example are
main.xml:
<?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:id="@+id/selection" android:layout_width="fill_parent" android:layout_height="wrap_content"/> <ListView android:id="@android:id/list" android:layout_width="fill_parent" android:layout_height="fill_parent" android:drawSelectorOnTop="false"/> </LinearLayout>and row.xml:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <ImageView android:contentDescription="@string/ok_icon" android:id="@+id/icon" android:layout_width="22dp" android:paddingLeft="2dp" android:paddingRight="2dp" android:paddingTop="2dp" android:layout_height="wrap_content" android:src="@drawable/ok"/> <TextView android:id="@+id/label" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="24sp"/> </LinearLayout>
and strings.xml:
<?xml version="1.0" encoding="utf-8"?> <resources> <string name="hello">Hello World, ListViewDynamicActivity!</string> <string name="app_name">ListViewDynamic</string> <string name="ok_icon">ok_icon</string> </resources>
The project including drawable looks like this:
Run it, and we see we have a ListView with different icons based on the context of the specific entry in the list.
The getView() implementation shown in the previous example works fine. However, every time the user scrolls, we should create a bunch of new View object to adjust the newly shown rows. It's not efficient and will drain battery.
In this section we'll have two efficient ways of using ListView:
- Recycling
- Using Holder
The getView() method receives a convertView.
public View getView(int position, View convertView, ViewGroup parent) {
When it's null we need to create a new View for row from scratch by inflating XML. If it's not null, which happens when the user scrolls the list, then it is actually the one of our View already created. So, when we do not have to create a new view, we will try to recycle the views of the rows that scrolled off the other end of the list.
Assuming that each of the rows has the same basic structure, we can use findViewById() to get at the individual widgets that make up the row and change their contents, and then return convertView from getView(), rather than create a whole new row.
Here is a new getView():
public View getView(int position, View convertView, ViewGroup parent) { View row=convertView; if(row==null) { LayoutInflater inflater=getLayoutInflater(); row=inflater.inflate(R.layout.row, parent, false); } TextView label=(TextView)row.findViewById(R.id.label); label.setText(SmartPhones[position]); ImageView icon=(ImageView)row.findViewById(R.id.icon); if (SmartPhones[position].startsWith("Samsung")) { icon.setImageResource(R.drawable.ok); } else { icon.setImageResource(R.drawable.radio); } return(row); }
Notice that the only change is the way we deal with the case when convertVew is null.
View row=convertView; if(row==null) { LayoutInflater inflater=getLayoutInflater(); row=inflater.inflate(R.layout.row, parent, false); }
As you see, we check to see if the convertView is null, if so, we inflate the row, otherwise, we just reuse it. In this way, we can avoid expensive inflating step.
The result is the same as in the previous example.
Another expensive operation is calling findViewById(). This goes into our inflated row and pulls out widgets by their identifiers, so we can customize the widget contents such as changing the text of a TextView or changing the icon in an ImageView. Since findViewById() can find widgets anywhere in the tree of children of the row's root View, this could take a fair number of instructions to execute, particularly if we need to fine the same widget repeatedly.
It would be nice if there is a way to cache the row's key child widgets while using the layout XML. That's where the holder pattern comes into play. All View objects have getTag() and setTag() methods. These allow us to associate an arbitrary object with the widget. The holder pattern uses that "tag" to hold an object which, in turn, holds each of the child widget of interest. By attaching that holder to the row View, every time we use the row, we already have access to the child widgets we care about without needing to call findViewById() again.
Let's look at one of holder classes.
ViewWrapper.java
package com.bogotobogo.WrapView; import android.view.View; import android.widget.ImageView; import android.widget.TextView; class ViewWrapper { View base; TextView label=null; ImageView icon=null; ViewWrapper(View base) { this.base=base; } TextView getLabel() { if (label==null) { label=(TextView)base.findViewById(R.id.label); } return(label); } ImageView getIcon() { if (icon==null) { icon=(ImageView)base.findViewById(R.id.icon); } return(icon); } }
ViewWrapper not only holds onto the child widgets, but it also lazy-finds the child widgets. If we create a wrapper, and don't need a specific child, we never use findViewById() to find it, and never have to pay for those CPU cycles to go through the tree./
The holder pattern has advantages as below.
- It allows us to consolidate all our per-widget type casting in on place rather than needing to cast everywhere we call findViewByid().
- We could use it to track other information about the row such as state information we are not yet ready to flush to the underlying model.
Using the ViewWrapper is a matter of creating an instance whenever we inflate a row and attaching instance to the row View using setTag().
Here is our main class file, WrapView.java:
package com.bogotobogo.WrapView; import android.os.Bundle; import android.app.ListActivity; import android.view.View; import android.view.ViewGroup; import android.view.LayoutInflater; import android.widget.ArrayAdapter; import android.widget.ListView; import android.widget.TextView; public class WrapViewActivity extends ListActivity { TextView selection; @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); setContentView(R.layout.main); setListAdapter(new IconicAdapter()); selection=(TextView)findViewById(R.id.selection); } private String getModel(int position) { return(((IconicAdapter)getListAdapter()).getItem(position)); } public void onListItemClick(ListView parent, View v, int position, long id) { selection.setText(getModel(position)); } class IconicAdapter extends ArrayAdapter<String> { IconicAdapter() { super(WrapViewActivity.this, R.layout.row, SmartPhones); } public View getView(int position, View convertView, ViewGroup parent) { View row=convertView; ViewWrapper wrapper=null; if (row==null) { LayoutInflater inflater=getLayoutInflater(); row=inflater.inflate(R.layout.row, parent, false); wrapper=new ViewWrapper(row); row.setTag(wrapper); } else { wrapper=(ViewWrapper)row.getTag(); } wrapper.getLabel().setText(getModel(position)); if (SmartPhones[position].startsWith("Samsung")) { wrapper.getIcon().setImageResource(R.drawable.ok); } else { wrapper.getIcon().setImageResource(R.drawable.radio); } return(row); } } static final String[] SmartPhones = new String[] { "HTC Rezound", "Samsung Galaxy S II Skyrocket", "Samsung Galaxy Nexus", "Motorola Droid Razr", "Samsung Galaxy S", "Samsung Epic Touch 4G", "iPhone 4S", "HTC Titan" }; }
We check convertView to see if it is null in order to create the row View objects as needed. We also pull or create the corresponding row's ViewWrapper. After that, accessing the child widgets is only a matter of calling their associated methods on the wrapper.
View row=convertView; ViewWrapper wrapper=null; if (row==null) { LayoutInflater inflater=getLayoutInflater(); row=inflater.inflate(R.layout.row, parent, false); wrapper=new ViewWrapper(row); row.setTag(wrapper); } else { wrapper=(ViewWrapper)row.getTag(); }
With the same xml files as in the Section 7.1 & 7.2, give us the same result.
Though it's nice to have lists with icons next to them, wouldn't it be much nicer if we can create ListView widgets whose rows contain interactive child widgets instead of just passive widgets like TextView and ImageView?
As an example, there is a RatingBar widget that allows users to assign a rating by clicking on a set of star icons.
RatingBar is an extension of SeekBar and ProgressBar that shows a rating in stars. The user can touch/drag or use arrow keys to set the rating when using the default size RatingBar. The smaller RatingBar style ( ratingBarStyleSmall) and the larger indicator-only style (ratingBarStyleIndicator) do not support user interaction and should only be used as indicators.
Could we combine the RatingBar with text in order to allow people to scroll a list of Android phones and rate them directly inside list?
Though the interactive widgets in rows work just fine, it is a little bit tricky when it comes to taking action while the interactive widget's state changes such as a value is typed into a field. We need to store that state somewhere because our RatingBar widget will be recycled when the ListView is scrolled. We need to be able to set the RatingBar state based on the actual word we are viewing as the RatingBar is recycled, and we need to save the state when it changes so that it can be restored when this particular row is scrolled back into our view.
What makes this interesting is that, by default, the RatingBar has absolute no idea which model in the ArrayAdapter it is looking at. After all, the RatingBar is just a widget, used in a row of a ListView. We need to tell the rows which model they are displaying, so when their rating bar is checked, they know which model's state to modify.
Let's see how it is working.
Our Java code, RateList.java:
package com.bogotobogo.RateList; import android.os.Bundle; import android.app.ListActivity; import android.view.View; import android.view.ViewGroup; import android.view.LayoutInflater; import android.widget.ArrayAdapter; import android.widget.RatingBar; import android.widget.LinearLayout; import android.widget.TextView; import java.util.ArrayList; public class RateListActivity extends ListActivity { @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); ArrayList<RowModel> list=new ArrayList<RowModel>(); for (String s : SmartPhones) { list.add(new RowModel(s)); } setListAdapter(new RatingAdapter(list)); } private RowModel getModel(int position) { return(((RatingAdapter)getListAdapter()).getItem(position)); } class RatingAdapter extends ArrayAdapter<RowModel> { RatingAdapter(ArrayList<RowModel> list) { super(RateListActivity.this, R.layout.row, list); } public View getView(int position, View convertView, ViewGroup parent) { View row=convertView; ViewWrapper wrapper; RatingBar rate; if (row==null) { LayoutInflater inflater=getLayoutInflater(); row=inflater.inflate(R.layout.row, parent, false); wrapper=new ViewWrapper(row); row.setTag(wrapper); rate=wrapper.getRatingBar(); RatingBar.OnRatingBarChangeListener l= new RatingBar.OnRatingBarChangeListener() { public void onRatingChanged(RatingBar ratingBar, float rating, boolean fromTouch) { Integer myPosition=(Integer)ratingBar.getTag(); RowModel model=getModel(myPosition); model.rating=rating; LinearLayout parent=(LinearLayout)ratingBar.getParent(); TextView label=(TextView)parent.findViewById(R.id.label); label.setText(model.toString()); } }; rate.setOnRatingBarChangeListener(l); } else { wrapper=(ViewWrapper)row.getTag(); rate=wrapper.getRatingBar(); } RowModel model=getModel(position); wrapper.getLabel().setText(model.toString()); rate.setTag(new Integer(position)); rate.setRating(model.rating); return(row); } } class RowModel { String label; float rating=2.0f; RowModel(String label) { this.label=label; } public String toString() { if (rating>=3.0) { return(label.toUpperCase()); } return(label); } } static final String[] SmartPhones = new String[] { "HTC Rezound", "Samsung Galaxy S II Skyrocket", "Samsung Galaxy Nexus", "Motorola Droid Razr", "Samsung Galaxy S", "Samsung Epic Touch 4G", "iPhone 4S", "HTC Titan" }; }
Notice that there are differences in this activity and getView() compared with the previous examples:
- While we are still using String[] for the list, rather than put that String array straight into an ArrayAdapter, we turn it into a list of RowModel objects.
class RatingAdapter extends ArrayAdapter<RowModel> { RatingAdapter(ArrayList<RowModel> list) { super(RateListActivity.this, R.layout.row, list); }
RowModel is the mutable model, it holds the list plus the current checked state. In a real system, these might be objects populated from a Cursor, and the properties would have more business meaning. - Utility methods like onListItemClick() needed to be updated to reflect the change from a pure String model to use a RowModel.
public void onListItemClick(ListView parent, View v, int position,long id) { selection.setText(items[position]); }
- The ArrayAdapter subclass, RatingAdapter, in getView(), looks to see if convertView is null. If so, we create a new row by inflating a simple layout and also attach a ViewWrapper.
LayoutInflater inflater=getLayoutInflater(); row=inflater.inflate(R.layout.row, parent, false); wrapper=new ViewWrapper(row); row.setTag(wrapper);
For the row's RatingBar, we add an anonymous onRatingChanged() listener that looks at the row's tag, getTag() and converts that into an Integer, representing the position within the ArrayAdapter that this row is displaying.
public void onRatingChanged(RatingBar ratingBar, float rating, boolean fromTouch) { Integer myPosition=(Integer)ratingBar.getTag();
Using that, the rating bar can get the actual RowModel for the row and update the model based on the new state of the rating bar. It also updates the text adjacent to the RatingBar when checked to match the rating bar state.
RowModel model=getModel(myPosition); model.rating=rating; LinearLayout parent=(LinearLayout)ratingBar.getParent(); TextView label=(TextView)parent.findViewById(R.id.label); label.setText(model.toString());
- We make sure that the RatingBar has the proper contents and has a tag via setTag() pointing to the position in the adapter the row is displaying.
wrapper.getLabel().setText(model.toString()); rate.setTag(new Integer(position))
The layout, row.xml, is just a RatingBar and a TextView inside a LinearLayout.
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <RatingBar android:id="@+id/rate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:numStars="3" android:stepSize="1" android:rating="2" /> <TextView android:id="@+id/label" android:paddingLeft="2px" android:paddingRight="2px" android:paddingTop="2px" android:textSize="24sp" android:layout_width="fill_parent" android:layout_height="wrap_content"/> </LinearLayout>
And another Java code, ViewWrapper.java, simply extracts the RatingBar and the TextView from the row View.
package com.bogotobogo.RateList; import android.view.View; import android.widget.RatingBar; import android.widget.TextView; class ViewWrapper { View base; RatingBar rate=null; TextView label=null; ViewWrapper(View base) { this.base=base; } RatingBar getRatingBar() { if (rate==null) { rate=(RatingBar)base.findViewById(R.id.rate); } return(rate); } TextView getLabel() { if (label==null) { label=(TextView)base.findViewById(R.id.label); } return(label); } }
Now, it's time to run our application. It shows if the row has highest rating, here 3, the text turns into all uppercase.
Success!
Here is the images for rate.png (star) used in this section.
Next sections:
- 7.5 Custom ListView - 3 subrows
- 7.6 Custom ListView with 3 Columns
- 7.7 Custom ListView with Collapsing Rows
- 7.8 Expandable ListView
Ph.D. / Golden Gate Ave, San Francisco / Seoul National Univ / Carnegie Mellon / UC Berkeley / DevOps / Deep Learning / Visualization