Lists with pretty icons next to them are all fine and well. But, can we create ListView widgets whose rows contain interactive child widgets instead of just passive widgets like TextView and ImageView? For example, there is a RatingBar widget that allows users to assign a rating by clicking on a set of star icons. Could we combine the RatingBar with text to allow people to scroll a list of, say, songs and rate them right inside the list? There is good news and bad news.
The good news is that interactive widgets in rows
work just fine. The bad news is that it is a little tricky, specifically
when it comes to taking action when the interactive widget's state
changes (e.g., a value is typed into a field). We need to store that
state somewhere, since 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 being viewed as the RatingBar
is recycled, and we need to save the state when it changes so it can be
restored when this particular row is scrolled back into view.
What makes this interesting is that, by default, the RatingBar has absolutely no idea which item in the ArrayAdapter it represents. After all, the RatingBar is just a widget, used in a row of a ListView. We need to teach the rows which item in the ArrayAdapter they are currently displaying, so when their RatingBar is checked, they know which item's state to modify.
So, let's see how this is done, using the activity in the FancyLists/RateList
sample project. We will use the same basic classes that we used in our
previous example. We are displaying a list of nonsense words, which can
then be rated. In addition, words given a top rating are put in all
caps.
package com.commonsware.android.fancylists.six;
import android.app.Activity;
import android.os.Bundle;
import android.app.ListActivity;
import android.view.View;
import android.view.ViewGroup;
import android.view.LayoutInflater;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.RatingBar;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import java.util.ArrayList;
public class RateListDemo extends ListActivity {
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet",
"consectetuer", "adipiscing", "elit", "morbi", "vel",
"ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
ArrayList<RowModel> list=new ArrayList<RowModel>();
for (String s : items) {
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(RateListDemo.this, R.layout.row, R.id.label, list);
}
public View getView(int position, View convertView,
ViewGroup parent) {
View row=super.getView(position, convertView, parent);
ViewHolder holder=(ViewHolder)row.getTag();
if (holder==null) {
holder=new ViewHolder(row);
row.setTag(holder);
RatingBar.OnRatingBarChangeListener l=
new RatingBar.OnRatingBarChangeListener() {
public void onRatingChanged(RatingBarratingBar,
float rating,
booleanfromTouch) {
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());
}
};
holder.rate.setOnRatingBarChangeListener(l);
}
RowModel model=getModel(position);
holder.rate.setTag(new Integer(position));
holder.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);
}
}
}
The following explains what is different in this activity and getView() implementation from before:
We are still using String[] items as the list of nonsense words, but instead of pouring that String array straight into an ArrayAdapter, we turn it into a list of RowModel objects. RowModel
is the mutable model: it holds the nonsense word plus the current
checked state. In a real system, these might be objects populated from a
database, and the properties would have more business meaning.
We updated utility methods such as onListItemClick() to reflect the change from a pure-String model to use a RowModel.
The ArrayAdapter subclass (RatingAdapter), in getView(), lets ArrayAdapter inflate and recycle the row, and then checks to see if we have a ViewHolder in the row's tag. If not, we create a new ViewHolder and associate it with the row. 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. 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.
We always 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.
The row layout is very simple, just a RatingBar and a TextView inside a LinearLayout:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayoutxmlns: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:padding="2dip"
android:textSize="18sp"
android:layout_gravity="left|center_vertical"
android:layout_width="fill_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
The ViewHolder is similarly simple, just extracting the RatingBar out of the row View for caching purposes:
package com.commonsware.android.fancylists.six;
import android.view.View;
import android.widget.RatingBar;
class ViewHolder {
RatingBar rate=null;
ViewHolder(View base) {
this.rate=(RatingBar)base.findViewById(R.id.rate);
}
}
And the result is what you would expect, visually, as shown in Figure 1.
Figure 2 shows a toggled rating bar turning its word into all caps.