July 30, 2014

Fancy ListViews, Part Six: Custom Widget

In this, the last and longest of our Fancy ListView posts, we’ll cover what it takes to wrap up the logic from the ChecklistDemo from a previous post and turn it into a reusable CheckListView that can serve as a drop-in replacement for ListView.

Before I go much further, though, please bear in mind that the next version of the Android SDK may have a similar component built into the framework. If so, I heartily encourage you to use the official one, for ease of long-term maintenance, and so your application is that much smaller. But, until then, or if you want to use the techniques shown here for some other custom ListView subclass, read on!

What we’d really like is to be able to create a layout like this:

[sourcecode language='xml']

xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent" >
android:id="@+id/selection"
android:layout_width="fill_parent"
android:layout_height="wrap_content"/>
android:id="@android:id/list"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:drawSelectorOnTop="false"
/> [/sourcecode]

where, in our code, almost all of the logic that might have referred to a ListView before “just works” with the CheckListView we put in the layout:

[sourcecode language='java']
public class CheckListViewDemo extends ListActivity {
TextView selection;
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);
setContentView(R.layout.main);

setListAdapter(new ArrayAdapter(this, android.R.layout.simple_list_item_1, items));
selection=(TextView)findViewById(R.id.selection);
}

public void onListItemClick(ListView parent, View v, int position, long id) {
selection.setText(items[position]);
}
}
[/sourcecode]

The CheckListView might offer some additional methods, such as getCheckedPositions() to get a list of position indexes that were checked, or getCheckedObjects() to get the actual objects that were checked.

Where things get a wee bit challenging is when you stop and realize that, in all our previous work with fancy ListViews, never were we actually changing the ListView itself. All our work was with the adapters, overriding getView() and inflating our own rows, and whatnot.

So if we want CheckListView to take in any ordinary ListAdapter and “just work”, putting checkboxes on the rows as needed, we are going to need to do some fancy footwork. Specifically, we are going to need to wrap the “raw” ListAdapter in some other ListAdapter that knows how to put the checkboxes on the rows and track the state of those checkboxes.

First, we need to establish the pattern of one ListAdapter augmenting another. Here is the code for AdapterWrapper, which takes a ListAdapter and delegates all of the interface’s methods to the delegate:

[sourcecode language='java']
public class AdapterWrapper implements ListAdapter {
ListAdapter delegate=null;

public AdapterWrapper(ListAdapter delegate) {
this.delegate=delegate;
}

public int getCount() {
return(delegate.getCount());
}

public Object getItem(int position) {
return(delegate.getItem(position));
}

public long getItemId(int position) {
return(delegate.getItemId(position));
}

public int getNewSelectionForKey(int currentSelection, int keyCode, KeyEvent event) {
return(delegate.getNewSelectionForKey(currentSelection, keyCode, event));
}

public View getView(int position, View convertView, ViewGroup parent) {
return(delegate.getView(position, convertView, parent));
}

public void registerDataSetObserver(DataSetObserver observer) {
delegate.registerDataSetObserver(observer);
}

public boolean stableIds() {
return(delegate.stableIds());
}

public void unregisterDataSetObserver(DataSetObserver observer) {
delegate.unregisterDataSetObserver(observer);
}

public boolean areAllItemsSelectable() {
return(delegate.areAllItemsSelectable());
}

public boolean isSelectable(int position) {
return(delegate. isSelectable(position));
}
}
[/sourcecode]

We can then subclass AdapterWrapper to create CheckableWrapper, overriding the default getView() but otherwise allowing the delegated ListAdapter to do the “real work”:

[sourcecode language='java']
public class CheckableWrapper extends AdapterWrapper {
Context ctxt=null;
boolean[] states=null;

public CheckableWrapper(Context ctxt, ListAdapter delegate) {
super(delegate);

this.ctxt=ctxt;
this.states=new boolean[delegate.getCount()];

for (int i=0;i this.states[i]=false;
}
}

public List getCheckedPositions() {
List result=new ArrayList();

for (int i=0;i if (states[i]) {
result.add(new Integer(i));
}
}

return(result);
}

public List getCheckedObjects() {
List result=new ArrayList();

for (int i=0;i if (states[i]) {
result.add(delegate.getItem(i));
}
}

return(result);
}

public View getView(int position, View convertView, ViewGroup parent) {
ViewWrapper wrap=null;
View row=convertView;

if (convertView==null) {
LinearLayout layout=new LinearLayout(ctxt);
CheckBox cb=new CheckBox(ctxt);
View guts=delegate.getView(position, null, parent);

layout.setOrientation(LinearLayout.HORIZONTAL);

cb.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.FILL_PARENT));
guts.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.FILL_PARENT,
LinearLayout.LayoutParams.FILL_PARENT));

cb.setOnCheckedChangeListener(
new CheckBox.OnCheckedChangeListener() {
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
states[(Integer)buttonView.getTag()]=isChecked;
}
});

layout.addView(cb);
layout.addView(guts);

wrap=new ViewWrapper(layout);
wrap.setGuts(guts);
layout.setTag(wrap);

cb.setTag(new Integer(position));
cb.setChecked(states[position]);

row=layout;
}
else {
wrap=(ViewWrapper)convertView.getTag();
wrap.setGuts(delegate.getView(position, wrap.getGuts(), parent));
wrap.getCheckBox().setTag(new Integer(position));
wrap.getCheckBox().setChecked(states[position]);
}

return(row);
}
}
[/sourcecode]

The idea is that CheckableWrapper is where most of our checklist logic resides. It puts the checkboxes on the rows and it tracks the checkboxes' states as they are checked and unchecked. For the states, it has a boolean[] sized to fit the number of rows that the delegate says are in the list.

CheckableWrapper's implementation of getView() is reminiscent of the one from ChecklistDemo, except that rather than use ViewInflate, we need to manually construct a LinearLayout to hold our CheckBox and the "guts" (a.k.a., whatever view the delegate created that we are decorating with the checkbox). ViewInflate is designed to construct a View from raw widgets; in our case, we don't know in advance what the rows will look like, other than that we need to add a checkbox to them. However, the rest is similar to the one from ChecklistDemo, including using a ViewWrapper (below), hooking onCheckedChanged() to have the checkbox update the state, and so forth:

[sourcecode language='java']
class ViewWrapper {
ViewGroup base;
View guts=null;
CheckBox cb=null;

ViewWrapper(ViewGroup base) {
this.base=base;
}

CheckBox getCheckBox() {
if (cb==null) {
cb=(CheckBox)base.getChildAt(0);
}

return(cb);
}

void setCheckBox(CheckBox cb) {
this.cb=cb;
}

View getGuts() {
if (guts==null) {
guts=base.getChildAt(1);
}

return(guts);
}

void setGuts(View guts) {
this.guts=guts;
}
}
[/sourcecode]

CheckableWrapper also has implementations of getCheckedPositions() and getCheckedObjects() that blend the state information with the delegate's data to return the selections as indexes or objects.

With all that in place, CheckListView is comparatively simple:

[sourcecode language='java']
public class CheckListView extends ListView {
public CheckListView(Context context) {
super(context);
}

public CheckListView(Context context, AttributeSet attrs, Map inflateParams) {
super(context, attrs, inflateParams);
}

public CheckListView(Context context, AttributeSet attrs, Map inflateParams, int defStyle) {
super(context, attrs, inflateParams, defStyle);
}

public void setAdapter(ListAdapter adapter) {
super.setAdapter(new CheckableWrapper(getContext(), adapter));
}

public List getCheckedPositions() {
return(((CheckableWrapper)getAdapter()).getCheckedPositions());
}

public List getCheckedObjects() {
return(((CheckableWrapper)getAdapter()).getCheckedObjects());
}
}
[/sourcecode]

We simply subclass ListView and override setAdapter() so we can wrap the supplied ListAdapter in our own CheckableWrapper. We also surface the getCheckedPositions() and getCheckedObjects() to complete the encapsulation, so users of CheckListView have no idea that there is a wrapper in use.

Visually, the results are similar to the ChecklistDemo:

A demo of the CheckListView in action

The difference is in reusability. We could package CheckListView in its own JAR and plop it into any Android project where we need it. So while CheckListView is somewhat complicated to write, we only have to write it once, and the rest of the application code is blissfully simple.

Of course, this CheckListView could use some more features, such as programmatically changing states (updating both the boolean[] and the actual CheckBox itself), allowing other application logic to be invoked when a CheckBox state is toggled (via some sort of callback), etc. These are left as exercises for the reader.

This concludes the Fancy ListView blog post series. After the next SDK is released, we will revisit these and other Building 'Droids posts, to let you know what all has changed that affects the code samples you've seen.

Next time, we'll talk about doing something in Android that Apple is doing its level best to prevent in the iPhone: on-device scripting.