Crossing things off lists in Android 0.9 SDK
A few months ago I wrote some code to let you “cross-off” things in a ListView. By wiping your finger left-to-right over an item it will add a strike-through effect to the text, and right-to-left would reverse the effect. Also, we’d like to store the crossed-off status in a backend database.
First, an overview of the problem. The ListView already captures clicks and long-touches for its items, and catches vertical scrolling and flinging to browse through the list. We’re interested in capturing any horizontal events while still letting normal touch events pass through to the ListView. The best way to handle this is by creating a wrapper View that will hold the ListView. Then we can use onInterceptTouchEvent() to watch for our cross-off actions, or otherwise ignore the touch events and let them trickle down to the ListView. (Thanks to Romain Guy for helping me find the correct way to capture these events.) Here’s the core of that capture code:
protected MotionEvent downStart = null;
public boolean onInterceptTouchEvent(MotionEvent event) {
switch(event.getAction()) {
case MotionEvent.ACTION_DOWN:
// keep track of the starting down-event
downStart = MotionEvent.obtain(event);
break;
case MotionEvent.ACTION_MOVE:
// if moved horizontally more than slop*2, capture the event for ourselves
float deltaX = event.getX() - downStart.getX();
if(Math.abs(deltaX) > ViewConfiguration.getTouchSlop() * 2)
return true;
break;
}
// otherwise let the event slip through to children
return false;
}
public boolean onTouchEvent(MotionEvent event) {
// check if we crossed an item
float targetWidth = this.getWidth() / 4;
float deltaX = event.getX() - downStart.getX(),
deltaY = event.getY() - downStart.getY();
boolean movedAcross = (Math.abs(deltaX) > targetWidth);
boolean steadyHand = (Math.abs(deltaX / deltaY) > 2);
if(movedAcross && steadyHand) {
boolean crossed = (deltaX > 0);
// figure out which child view we crossed
ListView list = (ListView)this.findViewById(android.R.id.list);
int position = list.pointToPosition((int)downStart.getX(), (int)downStart.getY());
// pass crossed event to any listeners
for(OnCrossListener listener : listeners) {
listener.onCross(position, crossed);
}
// and return true to consume this event
return true;
}
return false;
}
Using this approach lets us capture these cross-off gestures while still letting the ListView behave normally. I’m using this technique in my CompareEverywhere application, but earlier today I wrote a quick TodoList app to show it in action.
There are two other cool things we’re doing in the process: using a ViewBinder to custom render the created time of each item, and a stateful drawable to handle the checkmarks shown on each item. The ViewBinder correctly sets the strike-through text effect based on the COL_CROSSED database column, and also shows a custom caption with a format similar to “4 hours ago” based on the COL_CREATED column.
public boolean setViewValue(View view, Cursor cursor, int columnIndex) {
switch(view.getId()) {
case android.R.id.content:
// binding to parent container should set the crossed value
ImageView icon = (ImageView)view.findViewById(android.R.id.icon);
TextView text1 = (TextView)view.findViewById(android.R.id.text1),
text2 = (TextView)view.findViewById(android.R.id.text2);
// read crossed status and set text flags for strikethrough
boolean crossed = Boolean.valueOf(cursor.getString(columnIndex));
if(crossed) {
icon.setImageState(new int[] { android.R.attr.state_checked }, true);
text1.setPaintFlags(text1.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
text2.setPaintFlags(text2.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
} else {
icon.setImageState(new int[] { }, true);
text1.setPaintFlags(text1.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG);
text2.setPaintFlags(text2.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG);
}
return true;
case android.R.id.text2:
// binding to second textview should format time nicely
long created = cursor.getLong(columnIndex);
long now = System.currentTimeMillis() / 1000;
int minutes = (int)((now - created) / 60);
String nice = view.getContext().getString(R.string.bind_minutes, minutes);
if(minutes >= 60) {
int hours = (minutes / 60);
nice = view.getContext().getString(R.string.bind_hours, hours);
if(hours >= 24) {
int days = (hours / 24);
nice = view.getContext().getString(R.string.bind_days, days);
}
}
((TextView)view).setText(nice);
return true;
}
// otherwise fall through to other binding methods
return false;
}
And finally the code needed to connect the above ViewBinder to our SimpleCursorAdapter:
this.cross = (CrossView) this.findViewById(R.id.crossview);
this.list = (ListView) this.findViewById(android.R.id.list);
this.cross.addOnCrossListener(this);
// build adapter to show todo cursor
SimpleCursorAdapter adapter = new SimpleCursorAdapter(this, R.layout.item_todo, cursor,
new String[] { db.FIELD_LIST_TITLE, db.FIELD_LIST_CREATED, db.FIELD_LIST_CROSSED },
new int[] { android.R.id.text1, android.R.id.text2, android.R.id.content });
adapter.setViewBinder(new CrossBinder());
list.setAdapter(adapter);
And it really is that simple. The SimpleCursorAdapter shows our todo list, and the ViewBinder handles showing COL_CREATED correctly, and assigning the overall crossed-off state based on COL_CROSSED.
There is some additional code in our Activity to handle onCross() events and update the database and ListView as needed. Finally I threw in a context menu to handle deleting and crossing-off items on phones without a touchscreen, and a normal menu for adding new items.
The last cool thing is the stateful drawable that I’m using to automatically change the icon based on crossed-off status and also on selection:
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_selected="true" android:state_checked="true" android:drawable="@drawable/indicator_check_mark_dark_invert" /> <item android:state_checked="true" android:drawable="@drawable/indicator_check_mark_dark" /> <item android:state_selected="true" android:drawable="@drawable/ic_text_dot_c_invert" /> <item android:drawable="@drawable/ic_text_dot_c" /> </selector>
The ImageView automatically works its way down that list until it finds a drawable that matches all the state requirements, which makes it super easy to handle darkening icons when an item is selected. We also used ImageView.setImageState() earlier in our ViewBinder to correctly set the checked state.
If you’re interested, here’s the full Eclipse project source under a GPLv3 license, and also an APK ready to be installed. And here’s some video of the app in action:
