Advanced Lists
java -> android -> advanced lists ...We have previously created lists, both with a fixed number of elements and dynamically changing lists. They required three elements:
- a storage structure to hold the data, either an array or an ArrayList
- an ArrayAdapter to connect the data to the view
- a ListView to display and scroll the list and possibly handle list events
In fact, there is a fourth element needed: a template for displaying individual elements in the list. We have used an element defined in XML for that when we initialized the adapter.
Now we want to define lists with more sophisticated looks, appropriate for structured data. We need several ingredients:
- a class to store instances of the structured data
- a class to define the look of an instance of the data
- a class to hold a collection of data items and mediate between the data and a list
And of course we need a list to display the collection, scroll through it, and register events.
Example:
Say we want to display a list of addresses. Each address, say, consists of a name, a phone number, and an email address. Each name should be displayed bold and large, phone number in a different, non-proportional font, and the email in italics.
Later you could add event-handling capabilities where a long-tap brings up a context menu to dial the phone number, send an email, delete the entry, sort the data, add a new address, or edit an existing entry.
First, create a new Android project as usual, perhaps named AddressBook. Then create the storage class to contain an "address", of course in a new source code file:
public class Address { String first = null; String last = null; String phone = null; String email = null; public Address() { this("", "", "", ""); } public Address(String first, String last, String phone, String email) { this.first = first; this.last = last; this.phone = phone; this.email = email; } public String toString() { return last + ", " + first; } }
Next we create a class to serve a dual purpose: (a) to store a collection of Address items, and (b) to act as adapter between the data and the list. To accomplish (a) we add a field of type ArrayList to the class, as well as methods to add an address, retrieve an address, remove one address, and remove all addresses. As for (b), the class will extend BaseAdapter, which implies that we need to implement the methods:
- public int getCount() - to return the number of items currently stored
- public Object getItem(int pos) - to return the object at position pos
- public long getItemId(int pos) - to return the id of the item at position pos
- public View getView(int pos, View convertView, ViewGroup parent) - to return the view that renders the item at position pos
Most of these methods are self-explanatory, but getView requires more discussion. First, though, here is the complete class:
public class AddressAdapter extends BaseAdapter { private Context context; private ArrayList<Address> data = null; public AddressAdapter(Context context) { super(); this.context = context; this.data = new ArrayList<Address>(); } public void addAddress(Address address) { data.add(address); } public Address getAddress(int pos) { return data.get(pos); } public void remove(int pos) { data.remove(pos); } public void clear() { data.clear(); } public int getCount() { return data.size(); } public Object getItem(int pos) { return data.get(pos); } public long getItemId(int pos) { return 0; } public View getView(int pos, View convertView, ViewGroup parent) { TextView view = null; if (convertView != null) view = (TextView)convertView; else view = new TextView(context); view.setText(data.get(pos).toString()); return view; } }
The getView method is the one that is called to render the item at position pos. It first creates a reference variable view of type TextView. Then it checks it it can recycle the passed view convertView. If so, recycle it, otherwise create a new TextView instance. Then, in either case, set the text of the view to the data at position pos and return it. Note that only TextViews are instantiated when necessary, so that if convertView is not null, it must be a TextView and can safely be typecast as such.
Of course this will not give us a fancy list: each address is rendered by a text view, which we could have accomplished easier (just as in a previous section). However, we can now change the look easily and adapt it to our needs, but first let's turn this into a working activity by implementing the AddressBook class. For simplicity, we will use a ListActivity instead of an Activity, which is easy to use if your activity consists only of one list:
public class AddressBook extends ListActivity { ListView list = null; AddressAdapter adapter = null; public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); list = getListView(); adapter = new AddressAdapter(this); list.setAdapter(adapter); adapter.addAddress(new Address("John", "Doe", "555-1212", "jdoe@gmail.com")); adapter.addAddress(new Address("Jane", "Dear", "555-4567", "jdear@gmail.com")); } }
If we execute this activity, we will see the following, not very exciting, app:
Now let's jazz up the look of an item: we will add a "View" class that defines how to render an address and switch to that class in the getView method of our AddressAdapter class. For simplicity and flexibility, we will create the look of an address in code only, but you could certainly use an XML layout file if you prefer. Here is our first iteration of our rendering class:
package org.mathcs.addressbook; import android.content.Context; import android.graphics.Color; import android.graphics.Typeface; import android.util.TypedValue; import android.widget.LinearLayout; import android.widget.TextView; public class AddressView extends LinearLayout { public static final int BG_COLOR = Color.rgb(0, 0, 100); public static final int TXT_COLOR = Color.WHITE; public static final int TXT_SIZE_LARGE = 20; public static final int TXT_SIZE_SMALL = 14; private TextView name = null; private TextView phone = null; private TextView email = null; public AddressView(Context context) { super(context); this.setOrientation(LinearLayout.VERTICAL); this.setBackgroundColor(BG_COLOR); name = new TextView(context); name.setTypeface(Typeface.SANS_SERIF, Typeface.BOLD); name.setTextColor(TXT_COLOR); name.setTextSize(TypedValue.COMPLEX_UNIT_SP, TXT_SIZE_LARGE); name.setPadding(2, 5, 2, 5); phone = new TextView(context); phone.setTypeface(Typeface.MONOSPACE, Typeface.NORMAL); phone.setTextColor(TXT_COLOR); phone.setTextSize(TypedValue.COMPLEX_UNIT_SP, TXT_SIZE_SMALL); phone.setPadding(10, 2, 2, 2); email = new TextView(context); email.setTypeface(Typeface.SERIF, Typeface.ITALIC); email.setTextColor(TXT_COLOR); email.setTextSize(TypedValue.COMPLEX_UNIT_SP, TXT_SIZE_SMALL); email.setPadding(10, 2, 2, 2); LayoutParams params = new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.WRAP_CONTENT); this.addView(name, params); this.addView(phone, params); this.addView(email, params); } public void setData(Address address) { name.setText(address.last + ", " + address.first); if (!address.phone.equals("")) phone.setText("Phone: " + address.phone); if (!address.email.equals("")) email.setText("Email: " + address.email); } }
Note that when we define the font size we do so in "scalable" pixels, which will automatically scale the size to accommodate different screen resolutions. However, the padding is in pixels, which is no good since 10 pixels on a screen 100 pixels wide would occupy 10% of the width, but on a screen, say, 1000 pixels wide only takes up 1% of the width. We will, in the final discussion, address that problem, but now let's use our new view.
To use the new view on our data we only have to modify the getView method of the AddressAdapter class as follows:
public View getView(int pos, View convertView, ViewGroup parent) { AddressView view = null; if (convertView != null) view = (AddressView)convertView; else view = new AddressView(context); view.setData(data.get(pos)); return view; }
This will produce the following, almost perfect, look:
A few things are not right: The list background should match the item background, the scaling of the padding needs to accommodate for screen resolution, and - important - clicking/tapping on a list item no longer highlights the item as it did before (try it out) to give user feedback. To remedy the problems, we will:
- Move the color and font constants from AddressView to AddressBook since they define the overall colors
- Use a "scale" variable that we initialize once in the adapter class and pass into the view class
- Define a "selector" for the list in semi-transparent light blue
Here are, for easy reference, all three classes in their final form (the Address storage class does not change):
AddressBook.java
public class AddressBook extends ListActivity { public static final int BG_COLOR = Color.rgb(0, 0, 100); public static final int TXT_COLOR = Color.WHITE; public static final int TXT_SIZE_LARGE = 20; public static final int TXT_SIZE_SMALL = 14; public static final PaintDrawable LIST_SELECTOR = new PaintDrawable(Color.argb(150, 100, 100, 200)); ListView list = null; AddressAdapter adapter = null; public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); list = getListView(); adapter = new AddressAdapter(this); list.setAdapter(adapter); list.setBackgroundColor(BG_COLOR); list.setSelector(LIST_SELECTOR); list.setDrawSelectorOnTop(true); adapter.addAddress(new Address("John", "Doe", "555-1212", "jdoe@gmail.com")); adapter.addAddress(new Address("Jane", "Dear", "555-4567", "jdear@gmail.com")); } }
AddressView.java
public class AddressView extends LinearLayout { private TextView name = null; private TextView phone = null; private TextView email = null; public AddressView(Context context, float scale) { super(context); this.setOrientation(LinearLayout.VERTICAL); this.setBackgroundColor(AddressBook.BG_COLOR); name = new TextView(context); name.setTypeface(Typeface.SANS_SERIF, Typeface.BOLD); name.setTextColor(AddressBook.TXT_COLOR); name.setTextSize(TypedValue.COMPLEX_UNIT_SP, AddressBook.TXT_SIZE_LARGE); name.setPadding(2, 5, 2, 5); phone = new TextView(context); phone.setTypeface(Typeface.MONOSPACE, Typeface.NORMAL); phone.setTextColor(AddressBook.TXT_COLOR); phone.setTextSize(TypedValue.COMPLEX_UNIT_SP, AddressBook.TXT_SIZE_SMALL); phone.setPadding((int)(10*scale), 2, 2, 2); email = new TextView(context); email.setTypeface(Typeface.SERIF, Typeface.ITALIC); email.setTextColor(AddressBook.TXT_COLOR); email.setTextSize(TypedValue.COMPLEX_UNIT_SP, AddressBook.TXT_SIZE_SMALL); email.setPadding((int)(10*scale), 2, 2, 2); LayoutParams params = new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.WRAP_CONTENT); this.addView(name, params); this.addView(phone, params); this.addView(email, params); } public void setData(Address address) { name.setText(address.last + ", " + address.first); if (!address.phone.equals("")) phone.setText("Phone: " + address.phone); if (!address.email.equals("")) email.setText("Email: " + address.email); } }
AddressAdapter.java
public class AddressAdapter extends BaseAdapter { private Context context; private ArrayList<Address> data = null; private float scale = 1.0f; public AddressAdapter(Context context) { super(); this.context = context; this.data = new ArrayList<Address>(); this.scale = context.getResources().getDisplayMetrics().density; } public void addAddress(Address address) { data.add(address); } public Address getAddress(int pos) { return data.get(pos); } public void remove(int pos) { data.remove(pos); } public void clear() { data.clear(); } public int getCount() { return data.size(); } public Object getItem(int pos) { return data.get(pos); } public long getItemId(int pos) { return 0; } public View getView(int pos, View convertView, ViewGroup parent) { AddressView view = null; if (convertView != null) view = (AddressView)convertView; else view = new AddressView(context, scale); view.setData(data.get(pos)); return view; } }
This will produce a neat layout where an address when clicked changes to light blue:
Optional: Sorting the List
It is exceedingly easy to add a sort method to our adapter so the list can be sorted if desired:
- Make sure the Address class implements Comparable
- Add a sort method to the adapter class to sort the list via the static utility Collections.sort method
public class Address implements Comparable<Address> { // no changes except for adding the interface and implementing: public int compareTo(Address other ) { int compLastNames = last.compareTo(other.last); if (compLastNames == 0) return first.compareTo(other.first); else return compLastNames; } } public class AddressAdapter extends BaseAdapter { // no change except for: public void sortAddresses() { Collections.sort(data); } }
Note:
Whenever you call a method that changes the address data and you want to see your changes, do not forget to call on the adapter's notifyDataSetChanged() method, otherwise the list will not refresh!
Questions:
- How would a list item be rendered that has a missing phone number or email or both. Why?
- Adjust the look to give the phone number and email a slightly lighter blue background.
- Add more address and verify that scrolling works correctly.
- How exactly is the sort order defined? Sorted by last name, by first name, both?
- Why don't we include a call to notifyDataSetChanged() with each method that changes the data in the list?
- Add an on-item-click handler to dial the phone number tapped
- Add an on-item-long-click handler to send email to the selected address
- Add a context menu that allows deleting, editing, and adding addresses