07.19 控件-ExpandableListView

2024/10/16 Android 开发基础

ExpandableListView 是 AdapterView 类型的控件,简单理解是一个可折叠的列表,它是ListView的子类, 在ListView的基础上将列表项分为几组,每组里又可包含多个列表项。实现了一种嵌套列表的效果,但是需要使用ExpandableAdapter适配器。

# 基本属性和方法

常用属性:

  • android:childDivider:指定各组内子类表项之间的分隔条,图片不会完全显示, 分离子列表项的是一条直线
  • android:childIndicator:显示在子列表旁边的Drawable对象,可以是一个图像
  • android:childIndicatorEnd:子列表项指示符的结束约束位置
  • android:childIndicatorLeft:子列表项指示符的左边约束位置
  • android:childIndicatorRight:子列表项指示符的右边约束位置
  • android:childIndicatorStart:子列表项指示符的开始约束位置
  • android:groupIndicator:显示在组列表旁边的Drawable对象,可以是一个图像
  • android:indicatorEnd:组列表项指示器的结束约束位置
  • android:indicatorLeft:组列表项指示器的左边约束位置
  • android:indicatorRight:组列表项指示器的右边约束位置
  • android:indicatorStart:组列表项指示器的开始约束位置

# 实现ExpandableAdapter的几种方式

  • 扩展 BaseExpandableListAdpter 实现 ExpandableAdapter
  • 使用 SimpleExpandableListAdpater 将两个List集合包装成ExpandableAdapter
  • 使用 simpleCursorTreeAdapter 将Cursor中的数据包装成SimpleCuroTreeAdapter

# 基本使用示例

ExpandableListView 最常见的使用方式就是扩展 BaseExpandableListAdapter 实现适配器,绑定自定义的组布局和子项布局。下面就来看看这种常见的实现方式。

  1. 定义列表的数据项对象:组数据对象 GroupBean 和子项数据对象 ChildBean
/**
* 定义组数据对象.
*/
public class GroupBean {
    // 组图标
    private int icon;
    // 组标题
    private String title;
    // 组包含的子项数据
    private List<ChildBean> children;

    public GroupBean(int icon, String title, List<ChildBean> children) {
        this.icon = icon;
        this.title = title;
        this.children = children;
    }

    public int getIcon() {
        return icon;
    }

    public void setIcon(int icon) {
        this.icon = icon;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public List<ChildBean> getChildren() {
        return children;
    }

    public void setChildren(List<ChildBean> children) {
        this.children = children;
    }
}
/**
* 定义子项数据对象.
*/
public class ChildBean {
    // 子项内容
    private String content;

    public ChildBean(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}
  1. 扩展 BaseExpandableListAdapter 自定义实现适配器:ExpandableListViewAdapter
/**
* ExpandableListView 列表适配器.
*/
public class ExpandableListViewAdapter extends BaseExpandableListAdapter {
    private Context mContext;
    private List<GroupBean> mGroupBeans;

    public ExpandableListViewAdapter(Context context, List<GroupBean> groupBeans) {
        mContext = context;
        mGroupBeans = groupBeans;
    }

    // 返回组的数量
    @Override
    public int getGroupCount() {
        return mGroupBeans == null ? 0 : mGroupBeans.size();
    }

    // 返回指定组中子项的数量
    @Override
    public int getChildrenCount(int groupPosition) {
        if (mGroupBeans == null || mGroupBeans.get(groupPosition) == null) {
            return 0;
        }
        List<ChildBean> children = mGroupBeans.get(groupPosition).getChildren();
        return children == null ? 0 : children.size();
    }

    // 返回指定组的组数据对象
    @Override
    public Object getGroup(int groupPosition) {
        return mGroupBeans == null ? null : mGroupBeans.get(groupPosition);
    }

    // 返回指定组中指定位置的子项数据对象
    @Override
    public Object getChild(int groupPosition, int childPosition) {
        if (getGroup(groupPosition) == null) {
            return null;
        }
        List<ChildBean> children = mGroupBeans.get(groupPosition).getChildren();
        return children == null ? null : children.get(childPosition);
    }

    // 返回组的ID
    @Override
    public long getGroupId(int groupPosition) {
        return groupPosition;
    }

    // 返回子项的ID
    @Override
    public long getChildId(int groupPosition, int childPosition) {
        return childPosition;
    }

    // 组和子项的ID是否持有稳定的ID(跨底层数据的更改),一般返回false即可
    @Override
    public boolean hasStableIds() {
        return false;
    }

    // 返回指定组的View视图
    @Override
    public View getGroupView(int groupPosition, boolean isExpanded,
                             View convertView, ViewGroup parent) {
        if (convertView == null) {
            convertView = LayoutInflater.from(mContext)
                    .inflate(R.layout.layout_expandable_listview_group, parent, false);
        }
        ImageView ivIcon = convertView.findViewById(R.id.iv_icon);
        TextView tvTitle = convertView.findViewById(R.id.tv_title);
        TextView tvCount = convertView.findViewById(R.id.tv_count);
        GroupBean groupBean = mGroupBeans.get(groupPosition);
        ivIcon.setBackgroundResource(groupBean.getIcon());
        tvTitle.setText(groupBean.getTitle());
        tvCount.setText(String.format("(%d)", getChildrenCount(groupPosition)));
        return convertView;
    }

    // 返回指定组中指定位置的子项View视图
    @Override
    public View getChildView(int groupPosition, int childPosition,
                             boolean isLastChild, View convertView, ViewGroup parent) {
        if (convertView == null) {
            convertView = LayoutInflater.from(mContext)
                    .inflate(R.layout.layout_expandable_listview_child, parent, false);
        }
        TextView tvContent = convertView.findViewById(R.id.tv_content);
        ChildBean childBean = (ChildBean) getChild(groupPosition, childPosition);
        tvContent.setText(childBean.getContent());
        return convertView;
    }

    // 指定位置的子项是否可选中,一般返回true
    @Override
    public boolean isChildSelectable(int groupPosition, int childPosition) {
        return true;
    }
}
  1. 自定义组布局:layout_expandable_listview_group.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/iv_icon"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:layout_margin="10dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="10dp"
        android:textColor="#333333"
        android:textSize="16dp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@id/iv_icon"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_count"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="10dp"
        android:textColor="#888888"
        android:textSize="14dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
  1. 自定义子项布局:layout_expandable_listview_child.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/tv_content"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="10dp"
        android:textColor="#666666"
        android:textSize="15dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
  1. xml布局使用
<?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:gravity="center_horizontal"
    android:orientation="vertical">

    <ExpandableListView
        android:id="@+id/elv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:childDivider="@color/colorDivider"
        android:groupIndicator="@drawable/expandable_listview_indicator" />

</LinearLayout>

其中 android:groupIndicator 自定义组指示器(展开/收起状态对应不同的图片),使用 <selector> 的方式实现:expandable_listview_indicator.xml

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 展开状态 -->
    <item android:drawable="@mipmap/ic_indicator_expand" android:state_expanded="true" />
    <!-- 默认(收起状态) -->
    <item android:drawable="@mipmap/ic_indicator_collapse" />
</selector>
  1. 示例代码
// 初始化组的模拟数据
private List<GroupBean> initGroupData() {
    List<GroupBean> groups = new ArrayList<>();
    for (int i = 0; i < 4; i++) {
        // 初始化组数据
        int iconRes;
        String title;
        switch (i) {
            case 0:
                iconRes = R.drawable.icon_1;
                title = "春天";
                break;
            case 1:
                iconRes = R.drawable.icon_2;
                title = "夏天";
                break;
            case 2:
                iconRes = R.drawable.icon_3;
                title = "秋天";
                break;
            case 3:
                iconRes = R.drawable.icon_4;
                title = "冬天";
                break;
            default:
                iconRes = 0;
                title = "";
        }
        // 初始化子项数据
        List<ChildBean> children = new ArrayList<>();
        for (int j = 0; j < 3; j++) {
            ChildBean childBean = new ChildBean(String.format("%s的第%d项内容", title, j + 1));
            children.add(childBean);
        }
        GroupBean groupBean = new GroupBean(iconRes, title, children);
        groups.add(groupBean);
    }
    return groups;
}

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_demo);

    List<GroupBean> groups = initGroupData();
    ExpandableListView expandableListView = findViewById(R.id.elv);
    // 设置适配器
    ExpandableListViewAdapter adapter = new ExpandableListViewAdapter(this, groups);
    expandableListView.setAdapter(adapter);

    // 设置组点击监听(返回false则执行默认的展开/收起处理)
    expandableListView.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() {
        @Override
        public boolean onGroupClick(ExpandableListView parent, View v,
                                    int groupPosition, long id) {
            Toast.makeText(getApplicationContext(),
                    String.format("点击了第%d组:%s", groupPosition, groups.get(groupPosition).getTitle()),
                    Toast.LENGTH_SHORT).show();
            return false;
        }
    });

    // 设置组展开/收起状态变化监听
    expandableListView.setOnGroupExpandListener(new ExpandableListView.OnGroupExpandListener() {
        @Override
        public void onGroupExpand(int groupPosition) {
            Toast.makeText(getApplicationContext(),
                    String.format("第%d组展开", groupPosition),
                    Toast.LENGTH_SHORT).show();
        }
    });
    expandableListView.setOnGroupCollapseListener(new ExpandableListView.OnGroupCollapseListener() {
        @Override
        public void onGroupCollapse(int groupPosition) {
            Toast.makeText(getApplicationContext(),
                    String.format("第%d组收起", groupPosition),
                    Toast.LENGTH_SHORT).show();
        }
    });

    // 设置子项点击监听
    expandableListView.setOnChildClickListener(new ExpandableListView.OnChildClickListener() {
        @Override
        public boolean onChildClick(ExpandableListView parent, View v,
                                    int groupPosition, int childPosition, long id) {
            ChildBean childBean = groups.get(groupPosition).getChildren().get(childPosition);
            Toast.makeText(getApplicationContext(),
                    String.format("点击了第%d组第%d子项:%s", groupPosition, childPosition, childBean.getContent()),
                    Toast.LENGTH_SHORT).show();
            // 返回true表示消费本次点击事件
            return true;
        }
    });
}
  1. 展开/收起控制

ExpandableListView 默认是全部收起的状态,可以通过如下方法控制组的展开与收起:

ExpandableListView expandableListView = findViewById(R.id.elv);

// 展开指定组(第二个参数true表示带展开动画)
expandableListView.expandGroup(0, true);
// 收起指定组
expandableListView.collapseGroup(0);
// 指定组是否处于展开状态
boolean isExpanded = expandableListView.isGroupExpanded(0);
// 设置默认全部展开
for (int i = 0; i < adapter.getGroupCount(); i++) {
    expandableListView.expandGroup(i);
}

// setOnGroupExpandListener配合可实现"手风琴"效果:同一时间只展开一个组
expandableListView.setOnGroupExpandListener(new ExpandableListView.OnGroupExpandListener() {
    // 记录上一次展开的组
    private int lastExpandGroup = -1;

    @Override
    public void onGroupExpand(int groupPosition) {
        // 当有组展开时,收起上一次展开的组,实现互斥展开
        if (lastExpandGroup != -1 && lastExpandGroup != groupPosition) {
            expandableListView.collapseGroup(lastExpandGroup);
        }
        lastExpandGroup = groupPosition;
    }
});

# 隐藏组指示器

ExpandableListView 默认在组的旁边有一个展开/收起的箭头指示器(groupIndicator),如果不需要显示指示器,可以设置 android:groupIndicator="@null" 或调用 setGroupIndicator(null) 隐藏。

需要注意的是,隐藏指示器后,如果组布局没有其他展开/收起状态的提示,用户可能无法直观地看出组可以点击展开,建议在自定义组布局中自行实现一个随展开状态变化的图标(通过 getGroupView 中的 isExpanded 参数判断):

@Override
public View getGroupView(int groupPosition, boolean isExpanded,
                         View convertView, ViewGroup parent) {
    if (convertView == null) {
        convertView = LayoutInflater.from(mContext)
                .inflate(R.layout.layout_expandable_listview_group, parent, false);
    }
    // 通过isExpanded判断当前组的展开状态,切换对应的图标
    ImageView ivIndicator = convertView.findViewById(R.id.iv_indicator);
    ivIndicator.setBackgroundResource(isExpanded
            ? R.mipmap.ic_indicator_expand : R.mipmap.ic_indicator_collapse);
    return convertView;
}

# 数据更新

ExpandableListView 与 ListView 一样,数据的更新也是通过 Adapter 的 notifyDataSetChanged() 来通知刷新的,列表会重新刷新组与子项的视图。这里就不再重复展开了。

# 结束

本节介绍了 Android 中的可折叠列表控件(ExpandableListView)常用属性以及相关使用方法,自定义实现的组布局、子项布局和适配器的方法,以及组的展开/收起控制等,更多属性和用法请参考:ExpandableListView (opens new window)。