Search code examples
androidlistviewandroid-filterable

Custom Filtering of ListView with a Custom List Adapter including section headers


I am trying to filter a custom List View with a custom Adapter. I am having problems with duplicating the original Data and putting it back in the list, when the search parameter changes or goes to empty. The filtering does work for the first input character, but if this is changed, it won't search the whole dataset again. I know that this is because I need a duplicate list of the original data but I can't really get it to work, because I don't know how to properly implement it because I am using a custom Class as my Datatype. I only use the name and category property of it though, the names are the actual items and it is also sorted by categories.

I based my Adapter off of this example: https://gist.github.com/fjfish/3024308

And here is my code for the List Adapter:

class DataListAdapter extends BaseAdapter implements Filterable {

    private Context mContext;
    private List<Object> originalData = null;
    private List<Object> filteredData = null;
    private static final int CARRIER = 0;
    private static final int HEADER = 1;
    private LayoutInflater inflater;
    private ItemFilter mFilter = new ItemFilter();

    DataListAdapter(Context context, List<Object> input) {
        this.mContext = context;
        this.originalData = input;
        this.filteredData = input;
        this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }

    @Override
    public int getItemViewType(int position) {
        if (originalData.get(position) instanceof Carrier) {
            return CARRIER;
        } else {
            return HEADER;
        }
    }

    @Override
    public int getViewTypeCount() {
        return 2;
    }

    @Override
    public int getCount() {
        return originalData.size();
    }

    @Override
    public Object getItem(int position) {
        return originalData.get(position);
    }

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

    @SuppressLint("InflateParams")
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        if (convertView == null) {
            switch (getItemViewType(position)) {
                case CARRIER:
                    convertView = inflater.inflate(R.layout.listview_item_data_layout, null);
                    break;
                case HEADER:
                    convertView = inflater.inflate(R.layout.listview_header_data_layout, null);
                    break;
            }
        }

        switch (getItemViewType(position)) {
            case CARRIER:
                TextView name = (TextView) convertView.findViewById(R.id.fragment_data_list_view_carrier_name);
                name.setText(((Carrier) originalData.get(position)).get_name());
                break;
            case HEADER:
                TextView category = (TextView) convertView.findViewById(R.id.fragment_data_list_view_category);
                category.setText((String) originalData.get(position));
                break;
        }

        return convertView;
    }

    @Override
    public Filter getFilter() {
        return mFilter;
    }

    private class ItemFilter extends Filter {
        @Override
        protected FilterResults performFiltering(CharSequence constraint) {

            DatabaseHelper dbHelper = new DatabaseHelper(mContext, null, null, 1);
            String filterString = constraint.toString().toLowerCase();
            FilterResults results = new FilterResults();
            final List<Object> list = originalData;
            int count = list.size();
            final List<Object> nlist = new ArrayList<>(count);
            String filterableString = "";

            for (int i = 0; i < count; i++) {
                switch (getItemViewType(i)) {
                    case CARRIER:
                        filterableString = ((Carrier)list.get(i)).get_name();
                        break;
                    case HEADER:
                        filterableString = "";
                        break;
                }
                if(filterableString.toLowerCase().contains(filterString)) {
                    nlist.add(dbHelper.getCarriersWithName(filterableString).get(0));
                }
            }

            results.values = nlist;
            results.count = nlist.size();

            return results;
        }

        @SuppressWarnings("unchecked")
        @Override
        protected void publishResults(CharSequence constraint, FilterResults results) {
            if(results.count == 0) {
                notifyDataSetInvalidated();
            } else {
                originalData = (List<Object>)results.values;
                notifyDataSetChanged();
            }

        }
    }
}

My main activity obviously looks like this, which should be fine. The problem lays in the filtered Data List, which I can't get to work.

List<Object> combinedCategoryCarrierList = dbHelper.getCombinedCategoryCarrierList();
adapter = new DataListAdapter(mContext, combinedCategoryCarrierList);
listView.setAdapter(adapter);

listView.setTextFilterEnabled(true);

searchEditText = (EditText) view.findViewById(R.id.fragment_data_search);
searchEditText.addTextChangedListener(new TextWatcher() {
    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {

    }

    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {

    }

    @Override
    public void afterTextChanged(Editable s) {
        adapter.getFilter().filter(searchEditText.getText().toString());
    }
});

I would greatly appreciate it if someone can show me an example of how to do that with custom data types and section headers combined. Or even change my code :) I can't really find examples where all of that applies.

Edit: The screen looks like this, so I want to keep the category headers when filtering.


Solution

  • I did not find a solution to my original problem, but I came up with a better approach to the whole situation. I didn't know there was an ExpandableListView available in Android. This is basically a ListView, but the items are divided into Groups and their Childs which are expandable and collapsable, so exactly what I wanted.

    Here is how I implemented it with working filters and groups:

    So, to start off, here is my main layout file. Please note that I am using Fragments, which is why the code is a bit different in terms of getting the context for example. The functionality of the component stays the same though.

    <?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="match_parent"
        android:orientation="vertical" >
    
        <EditText
            android:id="@+id/fragment_data_search"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="text"
            android:hint="@string/data_search_hint"
            android:layout_marginTop="8dp"
            android:layout_marginBottom="8dp"
            android:layout_marginStart="10dp"
            android:layout_marginEnd="10dp" />
    
        <ExpandableListView
            android:id="@+id/fragment_data_expandable_list_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:groupIndicator="@null" />
    
    </LinearLayout>
    

    You will also need two layout files for your header/group items and for your child items. My header item has a TextView which displays the category name and an ImageView which displays a + or - to show if the category is collapsed or expanded.

    Here is my header layout file:

    <?xml version="1.0" encoding="utf-8"?>
    
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorAccent"
        android:descendantFocusability="blocksDescendants" >
    
        <TextView
            android:id="@+id/fragment_data_list_view_category"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentStart="true"
            android:gravity="start"
            android:textStyle="bold"
            android:textSize="18sp"
            android:paddingStart="16dp"
            android:paddingEnd="16dp"
            android:paddingBottom="8dp"
            android:paddingTop="8dp"
            android:textColor="@android:color/primary_text_light"
            android:text="@string/placeholder_header_listview"
            android:maxLines="1"
            android:ellipsize="end" />
    
        <ImageView
            android:id="@+id/fragment_data_list_view_category_icon"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentEnd="true"
            android:layout_gravity="end"
            android:paddingStart="16dp"
            android:paddingEnd="16dp"
            android:paddingBottom="8dp"
            android:paddingTop="8dp"
            android:contentDescription="@string/content_description_list_view_header"
            android:src="@drawable/ic_remove_black_24dp"
            android:tag="maximized"/>
    
    </RelativeLayout>
    

    The property android:descendantFocusability="blocksDescendants" fixed a bug when I tried setting an onItemClickListener. If you have that problem, try using RelativeLayout's for your child layout if you're not already. It fixed it for me, the onClickItemListener did not execute with a LinearLayout.

    And here is my layout file for the child items:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:paddingStart="16dp"
        android:paddingEnd="16dp"
        android:paddingTop="8dp"
        android:paddingBottom="8dp"
        android:descendantFocusability="blocksDescendants" >
    
            <TextView
                android:id="@+id/fragment_data_list_view_carrier_name"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@string/placeholder_item_listview"
                android:textSize="18sp"
                android:textStyle="normal"
                android:textColor="@android:color/primary_text_light"
                android:maxLines="1"
                android:ellipsize="end" />
    
    </RelativeLayout>
    

    The following code is from my fragment class, which handles all the logic for the ExpandableListView:

    public class Fragment_Data extends Fragment {
    
        private Context mContext;
    
        private ExpandableListView expandableListView;
        private List<String> categories_list;
        private HashMap<String, List<Carrier>> carriers_list;
        private DataExpandableListAdapter adapter;
    
        private DatabaseHelper dbHelper;
    
        @Override
        public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
            super.onViewCreated(view, savedInstanceState);
            getActivity().setTitle(R.string.nav_item_data);
        }
    

    This first part shows the declaration of needed variables and the necessary method onViewCreated. The Carrier class is a custom object with properties like name, category and so on. The DatabaseHelper is also a custom class which handley my database and gets all the data for me, which is casted into Carrier objects. You can of course use anything you like as data types.

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
    
        View view = inflater.inflate(R.layout.fragment_data_layout, container, false);
        mContext = getContext();
        expandableListView = (ExpandableListView) view.findViewById(R.id.fragment_data_expandable_list_view);
        dbHelper = new DatabaseHelper(mContext, null, null, 1);
    
        adapter = new DataExpandableListAdapter(mContext, categories_list, carriers_list);
    
        displayList();
    
        expandAllGroups();
    
        EditText searchEditText = (EditText) view.findViewById(R.id.fragment_data_search);
        searchEditText.addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
    
            }
    
            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {
                adapter.filterData(s.toString());
                expandAllGroups();
            }
    
            @Override
            public void afterTextChanged(Editable s) {
    
            }
        });
    
        expandableListView.setOnItemLongClickListener(deleteSelectedItem);
        expandableListView.setOnChildClickListener(editSelectedItem);
    
        return view;
    }
    

    The onCreate method deals with all the important stuff like setting the adapter, inflating the layout and setting onClick events for the items and a onTextChange event for the search field.

    private void expandAllGroups() {
        for(int i = 0; i < adapter.getGroupCount(); i++) {
            expandableListView.expandGroup(i);
        }
    }
    
    private void displayList() {
        prepareListData();
    
        adapter = new DataExpandableListAdapter(mContext, categories_list, carriers_list);
        expandableListView.setAdapter(adapter);
    
        expandAllGroups();
    }
    
    private void prepareListData() {
        categories_list = new ArrayList<>();
        carriers_list = new HashMap<>();
    
        categories_list = dbHelper.getCategoryList();
    
        for(int i = 0; i < categories_list.size(); i++) {
            List<Carrier> carrierList = dbHelper.getCarriersWithCategory(categories_list.get(i));
            carriers_list.put(categories_list.get(i), carrierList);
        }
    }
    

    With expandAllGroups() you can simply expand all groups, because they are collapsed by default. The displayList() simply sets the Adapter for the ExpandableListView and calls prepareListData(), which fills both the category (group) list and the carrier (child) list. Note that the child List is a hashmap with the key being the category and the value a Carrier List by itself, so the Adapter knows which child items belong to which parent.

    Here is the code for the Adapter:

    class DataExpandableListAdapter extends BaseExpandableListAdapter {
    
    private Context mContext;
    private List<String> list_categories = new ArrayList<>();
    private List<String> list_categories_original = new ArrayList<>();
    private HashMap<String, List<Carrier>> list_carriers = new HashMap<>();
    private HashMap<String, List<Carrier>> list_carriers_original = new HashMap<>();
    
    DataExpandableListAdapter(Context context, List<String> categories, HashMap<String, List<Carrier>> carriers) {
        this.mContext = context;
        this.list_categories = categories;
        this.list_categories_original = categories;
        this.list_carriers = carriers;
        this.list_carriers_original = carriers;
    }
    

    You need to have a copy of both of your original lists, if you want to use filtering. This is used for restoring all data when the search query is empty or again or simply different. The filter deletes all items that do not match from the original list.

    @Override
    public int getGroupCount() {
        return this.list_categories.size();
    }
    
    @Override
    public int getChildrenCount(int groupPosition) {
        return this.list_carriers.get(this.list_categories.get(groupPosition)).size();
    }
    
    @Override
    public Object getGroup(int groupPosition) {
        return this.list_categories.get(groupPosition);
    }
    
    @Override
    public Object getChild(int groupPosition, int childPosition) {
        return this.list_carriers.get(this.list_categories.get(groupPosition)).get(childPosition);
    }
    
    @Override
    public long getGroupId(int groupPosition) {
        return groupPosition;
    }
    
    @Override
    public long getChildId(int groupPosition, int childPosition) {
        return childPosition;
    }
    
    @Override
    public boolean hasStableIds() {
        return true;
    }
    
    @Override
        public boolean isChildSelectable(int groupPosition, int childPosition) {
            return true;
        }
    

    Those methods need to be overwritten when you expand the BaseExpandableListAdapter. You can replace all the return null; statements with something similar like this, depending on your data.

    @SuppressLint("InflateParams")
    @Override
    public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) {
    
        String headerTitle = (String) getGroup(groupPosition);
    
        if (convertView == null) {
            LayoutInflater inflater = (LayoutInflater) this.mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
            convertView = inflater.inflate(R.layout.listview_header_data_layout, null);
        }
    
        TextView lblListHeader = (TextView) convertView.findViewById(R.id.fragment_data_list_view_category);
        lblListHeader.setText(headerTitle);
    
        ImageView expandIcon = (ImageView) convertView.findViewById(R.id.fragment_data_list_view_category_icon);
        if(isExpanded) {
            expandIcon.setImageResource(R.drawable.ic_remove_black_24dp);
        } else {
            expandIcon.setImageResource(R.drawable.ic_add_black_24dp);
        }
    
        return convertView;
    }
    

    This overriden method simply inflates the layout for each header/group/category item and sets it text and image depending on the state of the group, if it's collapsed or expanded.

    @SuppressLint("InflateParams")
    @Override
    public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) {
    
            final String carrierName = ((Carrier)getChild(groupPosition, childPosition)).get_name();
    
            if (convertView == null) {
                LayoutInflater inflater = (LayoutInflater) this.mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
                convertView = inflater.inflate(R.layout.listview_item_data_layout, null);
            }
    
            TextView txtListChild = (TextView) convertView.findViewById(R.id.fragment_data_list_view_carrier_name);
    
            txtListChild.setText(carrierName);
            return convertView;
    }
    

    Same thing with the child items.

    Now finally to the filtering: I use this custom method to filter out all items that I need matching the search query. Remember that this method is called each time the text of the EditText changes.

    void filterData(String query) {
            query = query.toLowerCase();
            list_categories = new ArrayList<>(); 
            list_carriers = new HashMap<>();
    
        DatabaseHelper dbHelper = new DatabaseHelper(mContext, null, null, 1);
    
        if(query.trim().isEmpty()) {
            list_categories = new ArrayList<>(list_categories_original);
            list_carriers = new HashMap<>(list_carriers_original);
            notifyDataSetInvalidated();
        }
        else {
            //Filter all data with the given search query. Yes, it's complicated
            List<String> new_categories_list = new ArrayList<>();
            HashMap<String, List<Carrier>> new_carriers_list = new HashMap<>();
            List<String> all_categories_list = dbHelper.getCategoryList();
            for(int i = 0; i < all_categories_list.size(); i++) {
                List<Carrier> carriersWithCategoryList = dbHelper.getCarriersWithCategory(all_categories_list.get(i));
                List<Carrier> matchingCarriersInCategory = new ArrayList<>();
                for(Carrier carrierInCategory : carriersWithCategoryList) {
                    if(carrierInCategory.get_name().toLowerCase().contains(query)) {
                        matchingCarriersInCategory.add(carrierInCategory);
                        if(!new_categories_list.contains(all_categories_list.get(i))) {
                            new_categories_list.add(all_categories_list.get(i));
                        }
                    }
                }
                new_carriers_list.put(all_categories_list.get(i), matchingCarriersInCategory);
            }
    
            if(new_categories_list.size() > 0 && new_carriers_list.size() > 0) {
                list_categories.clear();
                list_categories.addAll(new_categories_list);
                list_carriers.clear();
                list_carriers.putAll(new_carriers_list);
            }
    
            notifyDataSetChanged();
        }
    }`
    

    This might be very confusing, but it needs to be that complicated in my case because of my data structure. It might be easier in your case.

    What this basically does is, that it first checks if the search query is empty. And if it is empty it resets both lists to the "backup" lists which I assigned in the constructor. I then call notifyDataSetInvalidated(); to tell the Adapter that it's content will be refilled. It might work aswell with notifyDataSetChanged();, I didn't test that, but it should since we set the original lists back to their old state.

    Now, if the search query is not empty I go through every category and see if that specific category has any items that match the search query. If that is the case, that item is added to a new child list and it's category/parent will also be added to a new parent list, if it's not already in there.

    And last but not least, the method checks if both lists are not empty. If they are not empty, the original lists are emptied and the new, filtered data, is put in and the Adapter is notified by calling notifyDataSetChanged();

    I hope this will help anyone.