I had long wanted to write a tree view demo. Previously, I used TreeViewList (a ListView wrapper) or ExpandableListView for multi-level menus. Eventually I realized there is no need for custom views – just use RecyclerView and manage the data flattening yourself.

The core idea: recursively flatten a nested data structure into a linear list, then use notifyItemRangeInserted / notifyItemRangeRemoved to handle expand and collapse.

Key Techniques

1. Unified Data Format

Convert all data sources into a tree structure with children for easy recursion:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
    "tree": {
        "children": [
            {
                "available": true,
                "children": [],
                "id": "548005da36ec3532c4a18391",
                "name": "First Round Review"
            }
        ],
        "id": "gaozhongshuxue",
        "name": "High School Math"
    }
}

2. Recursion-Friendly Entity

The entity class must be able to store all its child nodes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Course {
    @Expose
    @SerializedName("id")
    public String id;

    @Expose
    @SerializedName("name")
    public String name;

    public int level;
    public boolean open;
    public String parentId;
    public LinkedList<Course> children = new LinkedList<>();

    public boolean hasChild() {
        return children != null && children.size() > 0;
    }

    public void addChildren(LinkedList<Course> children) {
        this.children.clear();
        this.children.addAll(children);
    }
}

3. Recursive Data Construction

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private void createTree(Course container, JSONArray children,
                        String parentId, int level) throws JSONException {
    if (children != null) {
        int size = children.length();
        LinkedList<Course> tree = new LinkedList<>();
        for (int i = 0; i < size; i++) {
            JSONObject item = children.getJSONObject(i);
            Course course = new Course();
            course.id = item.getString("id");
            course.name = item.getString("name");
            course.level = level;
            course.open = false;
            course.parentId = parentId;
            JSONArray subChildren = item.getJSONArray("children");
            createTree(course, subChildren, course.id, level + 1);
            tree.add(course);
            if (container == null) {
                mData.add(course);
            }
        }
        if (container != null) {
            container.addChildren(tree);
        }
    }
}

4. Expand/Collapse on Click

When an item is clicked, check if it has children, then expand or collapse. The code uses notifyItemRangeInserted / notifyItemRangeRemoved for animation.

Why these methods over notifyDataSetChanged: the range-specific notifications preserve RecyclerView’s default animations, giving users a smooth visual transition during expand/collapse. notifyDataSetChanged would refresh the entire list, losing animations and incurring higher performance cost.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public boolean dispatchClick(LinkedList<Course> container, Course course) {
    if (container == null || course == null) {
        return false;
    }
    if (course.hasChild()) {
        int insertPosition = container.indexOf(course) + 1;
        if (course.open) {
            size = 0;
            removeAllChildren(container, course);
            notifyItemRangeRemoved(insertPosition, size);
        } else {
            course.open = true;
            container.addAll(insertPosition, course.children);
            notifyItemRangeInserted(insertPosition, course.children.size());
        }
        return true;
    }
    return false;
}

private void removeAllChildren(LinkedList<Course> container, Course course) {
    course.open = false;
    int childrenSize = course.children.size();
    for (Course child : course.children) {
        if (child.hasChild() && child.open) {
            child.open = false;
            removeAllChildren(container, child);
        }
    }
    size += childrenSize;
    container.removeAll(course.children);
}

5. Custom Animation (Optional)

Default RecyclerView animations may not show the expand effect clearly. Extend SimpleItemAnimator for a custom look:

1
2
3
4
5
6
7
8
9
@Override
public boolean animateAdd(final RecyclerView.ViewHolder holder) {
    resetAnimation(holder);
    ViewCompat.setTranslationY(holder.itemView,
        -(holder.itemView.getMeasuredHeight() / 2));
    ViewCompat.setAlpha(holder.itemView, 0);
    mPendingAdditions.add(holder);
    return true;
}

Source Code

Full example: GitHub: Haoxiqiang/TreeView

References