Monday 24 February 2014

Three Level Expandable ListView

How to create a 3 Level Expandable ListView

Recently I had to implement a 3 Level ExpandableListView. So, like most programmers I went searching for an existing solution. After a quick google search there appeared to be quite a few attempts at creating one. I say attempts because none of the ones I found on various blogs or github worked quite right.

After having a look at the source of the ExpandableListView it appears all that's happening behind the scenes is a fancy asynchronous filtering process for the list data. If the ExpandableListView is just a fancy filtering wrapper around a ListView there's no reason we can't make our own to use more levels.

To do this I used a regular ListView and BaseAdapter, a wrapper class, some Interfaces and an AsyncTask to do the filtering.

Here's how it works:

  • iterate over a list of all items and determine if it should be shown. 
  • it should be shown if its the top level item (it's parent is null) or every ancestor is expanded (its parent is expanded and so is its parent's parent and so on).
  • if it should be shown we add it to a filteredList and its this filteredList that is used by the ListView to determine what to show.
Let's take a look at the code.

First the NLevelListItem interface. Just a straight forward interface defining some methods we'll use later.

package com.twocentscode.nexpandable;

import android.view.View;

public interface NLevelListItem {

 public boolean isExpanded();
 public void toggle();
 public NLevelListItem getParent();
 public View getView();
}


Next is another interface, the NLevelView. This interface is what we'll use to get the View for each level.

package com.twocentscode.nexpandable;

import android.view.View;

public interface NLevelView {

 public View getView(NLevelItem item);
}


The NLevelItem, this implements the above NLevelListItem interface. Since this is just the concrete implementation of the interface the 2 things to note here is the Object wrappedObject and the NLevelView nLevelView. The wrappedObject is what will hold the data that backs the ListView views and the nLevelView will get the View for the BaseAdapter.

package com.twocentscode.nexpandable;

import android.view.View;

public class NLevelItem implements NLevelListItem {
 
 private Object wrappedObject;
 private NLevelItem parent;
 private NLevelView nLevelView;
 private boolean isExpanded = false;
 
 public NLevelItem(Object wrappedObject, NLevelItem parent, NLevelView nLevelView) {
  this.wrappedObject = wrappedObject;
  this.parent = parent;
  this.nLevelView = nLevelView;
 }
 
 public Object getWrappedObject() {
  return wrappedObject;
 }
 
 @Override
 public boolean isExpanded() {
  return isExpanded;
 }
 @Override
 public NLevelListItem getParent() {
  return parent;
 }
 @Override
 public View getView() {
  return nLevelView.getView(this);
 }
 @Override
 public void toggle() {
  isExpanded = !isExpanded;
 }
}


And now for the complex part, the NLevelAdapter. Its mostly just a standard BaseAdapter, except for the AsyncFilter class. The AsyncFilter does the filtering in the background, it simply iterates through the list of all NLevelItems and adds the top level items and any items whose ancestors are all expanded.

package com.twocentscode.nexpandable;

import java.util.ArrayList;
import java.util.List;

import android.os.AsyncTask;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;

public class NLevelAdapter extends BaseAdapter { 

 List<NLevelItem> list;
 List<NLevelListItem> filtered;
 public void setFiltered(ArrayList<NLevelListItem> filtered) {
  this.filtered = filtered;
  
 }
 public NLevelAdapter(List<NLevelItem> list) {
  this.list = list;
  this.filtered = filterItems();
 }
 
 @Override
 public int getCount() {
  return filtered.size();
 }

 @Override
 public NLevelListItem getItem(int arg0) {
  return filtered.get(arg0);
 }

 @Override
 public long getItemId(int arg0) {
  return 0;
 }

 @Override
 public View getView(int arg0, View arg1, ViewGroup arg2) {
  
  return getItem(arg0).getView();
 }

 public NLevelFilter getFilter() {
  return new NLevelFilter();
 }
 

 class NLevelFilter {

  public void filter() {
   new AsyncFilter().execute();
  }
  
  class AsyncFilter extends AsyncTask<Void, Void, ArrayList<NLevelListItem>> {

   @Override
   protected ArrayList<NLevelListItem> doInBackground(Void... arg0) {

    return (ArrayList<NLevelListItem>) filterItems();
   }
   
   @Override
   protected void onPostExecute(ArrayList<NLevelListItem> result) {
    setFiltered(result);
    NLevelAdapter.this.notifyDataSetChanged();
   }
  } 
  

 }
 
 public List<NLevelListItem> filterItems() {
  List<NLevelListItem> tempfiltered = new ArrayList<NLevelListItem>();
  OUTER: for (NLevelListItem item : list) {
   //add expanded items and top level items
   //if parent is null then its a top level item
   if(item.getParent() == null) {
    tempfiltered.add(item);
   } else {
    //go through each ancestor to make sure they are all expanded
    NLevelListItem parent = item;
    while ((parent = parent.getParent())!= null) {
     if (!parent.isExpanded()){
      //one parent was not expanded
      //skip the rest and continue the OUTER for loop
      continue OUTER;
     }
    }
    tempfiltered.add(item);
   }
  }
  
  return tempfiltered;
 }

 public void toggle(int arg2) {
  filtered.get(arg2).toggle();
 }
}


Now there's only two thinga left: how we build the list for the NLevelAdapter and how we provide the View for the getView() method in the adapter. So let's take a look at the MainActivity.

Here we create the data for the ListView, we create 5 'grandparents' (top level) NLevelItems, then for each grandparent we create a random number (between 1 and 5) of 'parents' (second level) NLevelItems and then for each parent we create a random number (between 1 and 6) of 'children' (third level) NLevelItems.

For each of the NLevelItems we pass in an anonymous instance of NLevelView, this is what supplies the View for the adapter for the each of the NLevelItems (grandparent, parent and children objects). For each of the different Views I'm using the same xml layout and just changing the background color for the different types, green for grandparents, yellow for parents and gray for children.

package com.twocentscode.nexpandable;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

import com.example.expandable3.R;

import android.app.Activity;
import android.graphics.Color;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.View;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ListView;
import android.widget.TextView;

public class MainActivity extends Activity {

 List<NLevelItem> list;
 ListView listView;
 
 @Override
 protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);
  listView = (ListView) findViewById(R.id.listView1);
  list = new ArrayList<NLevelItem>();
  Random rng = new Random();
  final LayoutInflater inflater = LayoutInflater.from(this);
  for (int i = 0; i < 5; i++) {
   
   final NLevelItem grandParent = new NLevelItem(new SomeObject("GrandParent "+i),null, new NLevelView() {
    
    @Override
    public View getView(NLevelItem item) {
     View view = inflater.inflate(R.layout.list_item, null);
     TextView tv = (TextView) view.findViewById(R.id.textView);
     tv.setBackgroundColor(Color.GREEN);
     String name = (String) ((SomeObject) item.getWrappedObject()).getName();
     tv.setText(name);
     return view;
    }
   });
   list.add(grandParent);
   int numChildren = rng.nextInt(4) + 1;
   for (int j = 0; j < numChildren; j++) {
    NLevelItem parent = new NLevelItem(new SomeObject("Parent "+j),grandParent, new NLevelView() {
     
     @Override
     public View getView(NLevelItem item) {
      View view = inflater.inflate(R.layout.list_item, null);
      TextView tv = (TextView) view.findViewById(R.id.textView);
      tv.setBackgroundColor(Color.YELLOW);
      String name = (String) ((SomeObject) item.getWrappedObject()).getName();
      tv.setText(name);
      return view;
     }
    });
  
    list.add(parent);
    int grandChildren = rng.nextInt(5)+1;
    for( int k = 0; k < grandChildren; k++) {
     NLevelItem child = new NLevelItem(new SomeObject("child "+k),parent, new NLevelView() {
      
      @Override
      public View getView(NLevelItem item) {
       View view = inflater.inflate(R.layout.list_item, null);
       TextView tv = (TextView) view.findViewById(R.id.textView);
       tv.setBackgroundColor(Color.GRAY);
       String name = (String) ((SomeObject) item.getWrappedObject()).getName();
       tv.setText(name);
       return view;
      }
     });
    
     list.add(child);
    }
   }
  }
  
  NLevelAdapter adapter = new NLevelAdapter(list);
  listView.setAdapter(adapter);
  listView.setOnItemClickListener(new OnItemClickListener() {

   @Override
   public void onItemClick(AdapterView<?> arg0, View arg1, int arg2,
     long arg3) {
    ((NLevelAdapter)listView.getAdapter()).toggle(arg2);
    ((NLevelAdapter)listView.getAdapter()).getFilter().filter();
    
   }
  });
 }
 
 class SomeObject {
  public String name;

  public SomeObject(String name) {
   this.name = name;
  }
  public String getName() {
   return name;
  }
 }

}


And finally the xml files used. activity_main.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context=".MainActivity" >

    <TextView
        android:id="@+id/textView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="NLevelExpandable ListView" />

    <ListView
        android:id="@+id/listView1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignLeft="@+id/textView1"
        android:layout_below="@+id/textView1" >

    </ListView>

</RelativeLayout>


And list_item.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical" 
    android:id="@+id/listItemContainer">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView" />

</LinearLayout>

You might have noticed everything is named NLevel... the reason being this code should handle an infinite number of level (in theory). Just nest more items as shown the MainActivity and more levels should be created. If you want you can grab the code from github.

Last but not least some screen shots.