CursorTreeAdapter with search implementation

4.2k views Asked by At

I'm making an application for and I'm using CursorTreeAdapter as ExpandableListView. I want to use a search box for displaying the filtered ExpandableListView items. Like this:

http://i.imgur.com/8ua7Mkl.png

Here's the code what I've written so far:

MainActivity.java:

package com.example.cursortreeadaptersearch;

import java.util.HashMap;

import android.app.SearchManager;
import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.provider.ContactsContract;
import android.support.v4.app.LoaderManager;
import android.support.v4.app.LoaderManager.LoaderCallbacks;
import android.support.v4.content.CursorLoader;
import android.support.v4.content.Loader;
import android.util.Log;
import android.widget.ExpandableListView;
import android.widget.SearchView;
import android.widget.SearchView.OnCloseListener;
import android.widget.SearchView.OnQueryTextListener;

import com.actionbarsherlock.app.SherlockFragmentActivity;

public class MainActivity extends SherlockFragmentActivity {

    private SearchView search;
    private MyListAdapter listAdapter;
    private ExpandableListView myList;

    private final String DEBUG_TAG = getClass().getSimpleName().toString();

    /**
     * The columns we are interested in from the database
     */
    static final String[] CONTACTS_PROJECTION = new String[] {
            ContactsContract.Contacts._ID,
            ContactsContract.Contacts.DISPLAY_NAME,
            ContactsContract.Contacts.PHOTO_ID,
            ContactsContract.CommonDataKinds.Email.DATA,
            ContactsContract.CommonDataKinds.Photo.CONTACT_ID };

    static final String[] GROUPS_SUMMARY_PROJECTION = new String[] {
            ContactsContract.Groups.TITLE, ContactsContract.Groups._ID,
            ContactsContract.Groups.SUMMARY_COUNT,
            ContactsContract.Groups.ACCOUNT_NAME,
            ContactsContract.Groups.ACCOUNT_TYPE,
            ContactsContract.Groups.DATA_SET };

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
        search = (SearchView) findViewById(R.id.search);
        search.setSearchableInfo(searchManager
                .getSearchableInfo(getComponentName()));
        search.setIconifiedByDefault(false);
        search.setOnQueryTextListener(new OnQueryTextListener() {

            @Override
            public boolean onQueryTextSubmit(String query) {
                listAdapter.filterList(query);
                expandAll();
                return false;
            }

            @Override
            public boolean onQueryTextChange(String query) {
                listAdapter.filterList(query);
                expandAll();
                return false;
            }
        });

        search.setOnCloseListener(new OnCloseListener() {

            @Override
            public boolean onClose() {
                listAdapter.filterList("");
                expandAll();
                return false;
            }
        });

        // get reference to the ExpandableListView
        myList = (ExpandableListView) findViewById(R.id.expandableList);
        // create the adapter
        listAdapter = new MyListAdapter(null, MainActivity.this);
        // attach the adapter to the list
        myList.setAdapter(listAdapter);

        Loader<Cursor> loader = getSupportLoaderManager().getLoader(-1);
        if (loader != null && !loader.isReset()) {
            runOnUiThread(new Runnable() {
                public void run() {
                    getSupportLoaderManager().restartLoader(-1, null,
                            mSpeakersLoaderCallback);
                }
            });
        } else {
            runOnUiThread(new Runnable() {
                public void run() {
                    getSupportLoaderManager().initLoader(-1, null,
                            mSpeakersLoaderCallback).forceLoad();
                    ;
                }
            });
        }

    }

    @Override
    public void onResume() {
        super.onResume();

        getApplicationContext().getContentResolver().registerContentObserver(
                ContactsContract.Data.CONTENT_URI, true,
                mSpeakerChangesObserver);
    }

    @Override
    public void onPause() {
        super.onPause();

        getApplicationContext().getContentResolver().unregisterContentObserver(
                mSpeakerChangesObserver);
    }

    // method to expand all groups
    private void expandAll() {
        int count = listAdapter.getGroupCount();
        for (int i = 0; i < count; i++) {
            myList.expandGroup(i);
        }
    }

    public LoaderManager.LoaderCallbacks<Cursor> mSpeakersLoaderCallback = new LoaderCallbacks<Cursor>() {

        @Override
        public Loader<Cursor> onCreateLoader(int id, Bundle args) {
            Log.d(DEBUG_TAG, "onCreateLoader for loader_id " + id);
            CursorLoader cl = null;

            HashMap<Integer, Integer> groupMap = listAdapter.getGroupMap();
            if (id != -1) {
                int groupPos = groupMap.get(id);
                if (groupPos == 0) { // E-mail group
                    String[] PROJECTION = new String[] {
                            ContactsContract.RawContacts._ID,
                            ContactsContract.CommonDataKinds.Email.DATA };
                    String sortOrder = "CASE WHEN "
                            + ContactsContract.Contacts.DISPLAY_NAME
                            + " NOT LIKE '%@%' THEN 1 ELSE 2 END, "
                            + ContactsContract.Contacts.DISPLAY_NAME + ", "
                            + ContactsContract.CommonDataKinds.Email.DATA
                            + " COLLATE NOCASE";
                    String selection = ContactsContract.CommonDataKinds.Email.DATA
                            + " NOT LIKE ''";
                    cl = new CursorLoader(getApplicationContext(),
                            ContactsContract.CommonDataKinds.Email.CONTENT_URI,
                            PROJECTION, selection, null, sortOrder);
                } else if (groupPos == 1) { // Name group
                    Uri contactsUri = ContactsContract.Data.CONTENT_URI;
                    String selection = "(("
                            + ContactsContract.CommonDataKinds.GroupMembership.DISPLAY_NAME
                            + " NOTNULL) AND ("
                            + ContactsContract.CommonDataKinds.GroupMembership.HAS_PHONE_NUMBER
                            + "=1) AND ("
                            + ContactsContract.CommonDataKinds.GroupMembership.DISPLAY_NAME
                            + " != '') AND ("
                            + ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID
                            + " = '1' ))"; // Row ID 1 == All contacts
                    String sortOrder = ContactsContract.CommonDataKinds.GroupMembership.DISPLAY_NAME
                            + " COLLATE LOCALIZED ASC";

                    cl = new CursorLoader(getApplicationContext(), contactsUri,
                            CONTACTS_PROJECTION, selection, null, sortOrder);
                }
            } else {
                // group cursor
                Uri groupsUri = ContactsContract.Groups.CONTENT_SUMMARY_URI;
                String selection = "((" + ContactsContract.Groups.TITLE
                        + " NOTNULL) AND (" + ContactsContract.Groups.TITLE
                        + " == 'Coworkers' ) OR ("
                        + ContactsContract.Groups.TITLE
                        + " == 'My Contacts' ))"; // Select only Coworkers
                                                 // (E-mail only) and My
                                                // Contacts (Name only)
                String sortOrder = ContactsContract.Groups.TITLE
                        + " COLLATE LOCALIZED ASC";
                cl = new CursorLoader(getApplicationContext(), groupsUri,
                        GROUPS_SUMMARY_PROJECTION, selection, null, sortOrder);
            }

            return cl;
        }

        @Override
        public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
            // Swap the new cursor in.
            int id = loader.getId();
//          Log.d("Dump Cursor MainActivity",
//                  DatabaseUtils.dumpCursorToString(data));
            Log.d(DEBUG_TAG, "onLoadFinished() for loader_id " + id);
            if (id != -1) {
                // child cursor
                if (!data.isClosed()) {
                    Log.d(DEBUG_TAG, "data.getCount() " + data.getCount());

                    HashMap<Integer, Integer> groupMap = listAdapter
                            .getGroupMap();
                    try {
                        int groupPos = groupMap.get(id);
                        Log.d(DEBUG_TAG, "onLoadFinished() for groupPos "
                                + groupPos);
                        listAdapter.setChildrenCursor(groupPos, data);
                    } catch (NullPointerException e) {
                        Log.w("DEBUG",
                                "Adapter expired, try again on the next query: "
                                        + e.getMessage());
                    }
                }
            } else {
                listAdapter.setGroupCursor(data);
            }
        }

        @Override
        public void onLoaderReset(Loader<Cursor> loader) {
            // This is called when the last Cursor provided to onLoadFinished()
            // is about to be closed.
            int id = loader.getId();
            Log.d(DEBUG_TAG, "onLoaderReset() for loader_id " + id);
            if (id != 1) {
                // child cursor
                try {
                    listAdapter.setChildrenCursor(id, null);
                } catch (NullPointerException e) {
                    Log.w(DEBUG_TAG,
                            "Adapter expired, try again on the next query: "
                                    + e.getMessage());
                }
            } else {
                listAdapter.setGroupCursor(null);
            }
        }
    };

    private ContentObserver mSpeakerChangesObserver = new ContentObserver(
            new Handler()) {

        @Override
        public void onChange(boolean selfChange) {
            if (getApplicationContext() != null) {
                runOnUiThread(new Runnable() {
                    public void run() {
                        getSupportLoaderManager().restartLoader(-1, null,
                                mSpeakersLoaderCallback);
                    }
                });
            }
        }
    };
}

MyListAdapter.java:

package com.example.cursortreeadaptersearch;

import java.util.HashMap;

import android.content.Context;
import android.database.Cursor;
import android.provider.ContactsContract;
import android.support.v4.content.Loader;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CursorTreeAdapter;
import android.widget.TextView;

public class MyListAdapter extends CursorTreeAdapter {

    public HashMap<String, View> childView = new HashMap<String, View>();

    /**
     * The columns we are interested in from the database
     */

    private final String DEBUG_TAG = getClass().getSimpleName().toString();

    protected final HashMap<Integer, Integer> mGroupMap;

    private MainActivity mActivity;
    private LayoutInflater mInflater;

    String mConstraint;

    public MyListAdapter(Cursor cursor, Context context) {

        super(cursor, context);
        mActivity = (MainActivity) context;
        mInflater = LayoutInflater.from(context);
        mGroupMap = new HashMap<Integer, Integer>();
    }

    @Override
    public View newGroupView(Context context, Cursor cursor,
            boolean isExpanded, ViewGroup parent) {

        final View view = mInflater.inflate(R.layout.list_group, parent, false);
        return view;
    }

    @Override
    public void bindGroupView(View view, Context context, Cursor cursor,
            boolean isExpanded) {

        TextView lblListHeader = (TextView) view
                .findViewById(R.id.lblListHeader);

        if (lblListHeader != null) {
            lblListHeader.setText(cursor.getString(cursor
                    .getColumnIndex(ContactsContract.Groups.TITLE)));
        }
    }

    @Override
    public View newChildView(Context context, Cursor cursor,
            boolean isLastChild, ViewGroup parent) {

        final View view = mInflater.inflate(R.layout.list_item, parent, false);

        return view;
    }

    @Override
    public void bindChildView(View view, Context context, Cursor cursor,
            boolean isLastChild) {

        TextView txtListChild = (TextView) view.findViewById(R.id.lblListItem);

        if (txtListChild != null) {
            txtListChild.setText(cursor.getString(1)); // Selects E-mail or
                                                        // Display Name
        }

    }

    protected Cursor getChildrenCursor(Cursor groupCursor) {
        // Given the group, we return a cursor for all the children within that
        // group
        int groupPos = groupCursor.getPosition();
        int groupId = groupCursor.getInt(groupCursor
                .getColumnIndex(ContactsContract.Groups._ID));

        Log.d(DEBUG_TAG, "getChildrenCursor() for groupPos " + groupPos);
        Log.d(DEBUG_TAG, "getChildrenCursor() for groupId " + groupId);

        mGroupMap.put(groupId, groupPos);

        Loader loader = mActivity.getSupportLoaderManager().getLoader(groupId);
        if (loader != null && !loader.isReset()) {
            mActivity.getSupportLoaderManager().restartLoader(groupId, null,
                    mActivity.mSpeakersLoaderCallback);
        } else {
            mActivity.getSupportLoaderManager().initLoader(groupId, null,
                    mActivity.mSpeakersLoaderCallback);
        }

        return null;
    }

    // Access method
    public HashMap<Integer, Integer> getGroupMap() {
        return mGroupMap;
    }

    public void filterList(CharSequence constraint) {
        // TODO Filter the data here
    }
}

I have very considerably simplified and cleaned the code (so that you guys that not need to do).

As you can see, I've in total 3 cursors (1 for the groups and 2 for the children). The data is get from ContactsContract (which are the contacts of the user). The cursor from child 1 represents all the e-mails of all contacts and the cursor from child 2 represents all the display names of the contacts. (The most of the loader functions is from here).

The only thing is now how do I implement a search? Should I do it trough Content Provider or a raw query in the database? I would like that the results of both children tables is displayed. I think because it's easy to make a fault while typing that tokenize=porter is a option in my case.

I hope that someone can point me in a good direction.

Edit:

I've tried this in MyListAdapter.java (with FilterQueryProvider as suggested by Kyle I.):

public void filterList(CharSequence constraint) {
    final Cursor oldCursor = getCursor();
    setFilterQueryProvider(filterQueryProvider);
    getFilter().filter(constraint, new FilterListener() {
        public void onFilterComplete(int count) {
            // assuming your activity manages the Cursor 
            // (which is a recommended way)
            notifyDataSetChanged();
//          stopManagingCursor(oldCursor);
//          final Cursor newCursor = getCursor();
//          startManagingCursor(newCursor);
//          // safely close the oldCursor
            if (oldCursor != null && !oldCursor.isClosed()) {
                oldCursor.close();
            }
        }
    });
}

private FilterQueryProvider filterQueryProvider = new FilterQueryProvider() {
    public Cursor runQuery(CharSequence constraint) {
        // assuming you have your custom DBHelper instance 
        // ready to execute the DB request
        String s = '%' + constraint.toString() + '%';
        return mActivity.getContentResolver().query(ContactsContract.Data.CONTENT_URI,
                MainActivity.CONTACTS_PROJECTION,
                ContactsContract.CommonDataKinds.GroupMembership.DISPLAY_NAME + " LIKE ?",
            new String[] { s },
            null);
    }
};

And this in MainActivity.java:

        search.setOnQueryTextListener(new OnQueryTextListener() {

            @Override
            public boolean onQueryTextSubmit(String query) {
                listAdapter.filterList(query);
                expandAll();
                return false;
            }
        
            @Override
            public boolean onQueryTextChange(String query) {
                listAdapter.filterList(query);
                expandAll();
                return false;
            }
        });

        search.setOnCloseListener(new OnCloseListener() {

            @Override
            public boolean onClose() {
                listAdapter.filterList("");
                expandAll();
                return false;
            }
        });

But then I get these errors when I try to search:

12-20 13:20:19.449: E/CursorWindow(28747): Failed to read row 0, column -1 from a CursorWindow which has 96 rows, 4 columns.
12-20 13:20:19.449: D/AndroidRuntime(28747): Shutting down VM
12-20 13:20:19.449: W/dalvikvm(28747): threadid=1: thread exiting with uncaught exception (group=0x415c62a0)
12-20 13:20:19.499: E/AndroidRuntime(28747): FATAL EXCEPTION: main
12-20 13:20:19.499: E/AndroidRuntime(28747): java.lang.IllegalStateException: Couldn't read row 0, col -1 from CursorWindow.  Make sure the Cursor is initialized correctly before accessing data from it.

What I'm doing wrong? Or is this because I'm only return 1 query (display names) instead of 2 (display names and e-mails) in runQuery?

Edit 2:

First of all I've changed all my database implementations to ContactsContract. This is become easier to maintain so that you don't have to write your own database implementation.

What I now have tried is to save my constraint in runQuery() of FilterQueryProvider, and then in getChildrenCursor run a query against that constraint. (as suggested by JRaymond)

private String mConstraint;
protected Cursor getChildrenCursor(Cursor groupCursor) {
    // Given the group, we return a cursor for all the children within that
    // group
    int groupPos = groupCursor.getPosition();
    int groupId = groupCursor.getInt(groupCursor
            .getColumnIndex(ContactsContract.Groups._ID));

    Log.d(DEBUG_TAG, "getChildrenCursor() for groupPos " + groupPos);
    Log.d(DEBUG_TAG, "getChildrenCursor() for groupId " + groupId);

    mGroupMap.put(groupId, groupPos);

    Bundle b = new Bundle();
    b.putString("constraint", mConstraint);

    Loader loader = mActivity.getSupportLoaderManager().getLoader(groupId);
    if (loader != null && !loader.isReset()) {
        if (mConstraint == null || mConstraint.isEmpty()) {
            // Normal query
            mActivity.getSupportLoaderManager().restartLoader(groupId,
                    null, mActivity.mSpeakersLoaderCallback);
        } else {
            // Constrained query
            mActivity.getSupportLoaderManager().restartLoader(groupId, b,
                    mActivity.mSpeakersLoaderCallback);

        }
    } else {
        if (mConstraint == null || mConstraint.isEmpty()) {
            // Normal query
            mActivity.getSupportLoaderManager().initLoader(groupId, null,
                    mActivity.mSpeakersLoaderCallback);
        } else {
            // Constrained query
            mActivity.getSupportLoaderManager().initLoader(groupId, b,
                    mActivity.mSpeakersLoaderCallback);
        }
    }

    return null;
}

And here is the FilterQueryProvider:

private FilterQueryProvider filterQueryProvider = new FilterQueryProvider() {
    public Cursor runQuery(CharSequence constraint) {
        // Load the group cursor here and assign mConstraint
        mConstraint = constraint.toString();
        Uri groupsUri = ContactsContract.Groups.CONTENT_SUMMARY_URI;
        String selection = "((" + ContactsContract.Groups.TITLE
                + " NOTNULL) AND (" + ContactsContract.Groups.TITLE
                + " == 'Coworkers' ) OR (" + ContactsContract.Groups.TITLE
                + " == 'My Contacts' ))"; // Select only Coworkers
                                            // (E-mail only) and My
                                            // Contacts (Name only)
        String sortOrder = ContactsContract.Groups.TITLE
                + " COLLATE LOCALIZED ASC";
        return mActivity.getContentResolver().query(groupsUri,
                MainActivity.GROUPS_SUMMARY_PROJECTION, selection, null,
                sortOrder);
    }
};

As you can see I've load the query of the groups in order to get the getChildrenCursor working. Only what for query should I run in MainActivity that I get from the bundle?

2

There are 2 answers

6
JRaymond On BEST ANSWER

I've looked into your issue, and unfortunately I don't have time to replicate your setup. In generic terms, however, You should be able to save your constraint, and then in 'getChildrenCursor', run a query against that constraint:

Cursor getChildrenCursor(Cursor groupCursor) {
  if (mConstraint == null || mConstraint.isEmpty()) {
    // Normal query
  } else {
    // Constrained query
  }

}

I'm not certain, but I'm pretty sure that getChildrenCursor() will get called in response to a change of the parent cursor when you return the cursor in the filterQueryProvider(). You then just manage the null/filled state of the constraint.

Details:

In your filterList function, instead of doing a complicated procedure, just call runQueryOnBackgroundThread(constraint);. This will automatically offload database work to the background. Save your constraint in your filterQueryProvider:

String s = '%' + constraint.toString() + '%';
mConstraint = s;

For the query, it just depends on what you're trying to get out of the database - a quick adjustment to the code you posted runs the query like so:

String selection = ContactsContract.CommonDataKinds.Email.DATA
    + " NOT LIKE ''";
if (constraint != null) {
    selection += " AND " + ContactsContract.CommonDataKinds.Email.DATA + " LIKE ?";
}
cl = new CursorLoader(getApplicationContext(), 
    ContactsContract.CommonDataKinds.Email.CONTENT_URI,
    PROJECTION, selection, constraint, sortOrder);

The one thing I'm not too sure about is the auto expand thing you have going, My filter works but you need to collapse and open the list again to see the change.

1
Kyle Ivey On

What you should to do is extend FilterQueryProvider. This provides a runQuery() function that returns a new cursor of filtered results (likely accomplished with a database query).

In your CursorTreeAdapter adapter implementation you will then use the setFilterQueryProvider() method to provide it an instance of your FilterQueryProvider.

Finally, when you want to perform filtering you will call mAdapter.getFilter().filter("c").

However seeing as you are not actually using the SearchView autocomplete features and instead populating your own list, your chosen solution is quite a bit more complicated than it needs to be. Why don't you instead drop the Content Provider and CursorTreeAdapter and use a more simple in-memory scheme of lists or maps to back your adapter? Populate the in-memory data as required (can your entire dataset fit in memory?).