很早想写这样一个 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