很早想写这样一个 Demo。以前实现树形菜单使用 TreeViewList(继承 ListView 的封装),或者用 ExpandableListView 实现多级菜单。后来发现根本不需要自定义控件——直接使用 RecyclerView,只需要控制数据源的展平转换即可。

核心思路:以递归方式将嵌套数据结构展平为线性列表,通过 notifyItemRangeInserted / notifyItemRangeRemoved 控制展开和收起。

技术要点

1. 统一数据格式

无论原始数据源是什么格式,都转换为带 children 的树形结构,方便递归处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
    "tree": {
        "children": [
            {
                "available": true,
                "children": [],
                "id": "548005da36ec3532c4a18391",
                "name": "第一轮复习"
            }
        ],
        "id": "gaozhongshuxue",
        "name": "高中数学"
    }
}

2. 递归友好的实体类

实体类需要能存储其全部子节点:

 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. 递归构建树形数据

 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. 点击展开/收起

每次点击 item 时判断该节点是否有子节点,然后分发展开或收起操作。使用 notifyItemRangeInserted / notifyItemRangeRemoved 实现动画效果。如果不需要动画,可以在 dispatchClick 返回 true 时直接调用 notifyDataSetChanged

 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);
}

为什么选择 notifyItemRangeInserted/Removed 而非 notifyDataSetChanged:前者保留 RecyclerView 的默认动画效果,用户能直观看到节点展开/收起的过程,体验更流畅。后者会刷新整个列表、丢失动画,且性能开销更大。

5. 自定义动画(可选)

RecyclerView 默认动画可能看不出弹出效果。可以继承 SimpleItemAnimator 自定义:

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;
}

源码

完整示例代码见 GitHub: Haoxiqiang/TreeView

参考