组件使用之ExpandableListView

组件使用之ExpandableListView

  安卓的各种组件中,ListView是一个非常常见的组件,它用于展示列表或者可以放入列表的各种数据;但是ListView也是有它的局限性的,首先就是要用它实现按照每个列表项展开子表这种需求会非常麻烦,关于位置的控制以及点击事件的处理都很繁琐,不巧的是类似的需求非常常见,而且这种展开子表的交互对用户来说也是十分友好的,iOS有原生控件支持这种需求,安卓自然也不甘示弱地有了ExpandableListView这个组件。


ExpandableListView简介

  ExpandableListView的使用和ListView非常相似,都是利用Adapter来将展示与数据区分开来,而且重用机制也是相同的,不同的地方只在于ExpandableListView要多一个获取子项的方法。

public class MyAdapter extends BaseExpandableListAdapter {
    @Override
    public int getGroupCount() {
        return 0;
    }
    @Override
    public int getChildrenCount(int groupPosition) {
        return 0;
    }
    @Override
    public Object getGroup(int groupPosition) {
        return null;
    }
    @Override
    public Object getChild(int groupPosition, int childPosition) {
        return null;
    }
    @Override
    public long getGroupId(int groupPosition) {
        return 0;
    }
    @Override
    public long getChildId(int groupPosition, int childPosition) {
        return 0;
    }
    @Override
    public boolean hasStableIds() {
        return false;
    }
    @Override
    public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) {
        return null;
    }
    @Override
    public View getChildView(int groupPosition, int childPosition,  boolean isLastChild, View convertView, ViewGroup parent) {
        return null;
    }
    @Override
    public boolean isChildSelectable(int groupPosition, int childPosition) {
        return false;
    }
}

  如上所示,这是继承了BaseExpandableListAdapter后需要实现的接口方法,看起来很复杂繁琐,但仔细解析就会发现其实和ListView的BaseAdapter异曲同工。

  • getGroupCount
    • 该方法类似BaseAdapter中的getCount方法,用于返回列表项目数量
    • 此处返回组别数量,即可展开项目的数量
  • getChildrenCount
    • 相对应的,该方法返回指定组别的展开子项数量
    • 参数是指定组别的索引
  • getGroup
    • 该方法类似BaseAdapter中的getItem方法,可用于获取指定索引的组别数据
    • 使用时需要强制类型转换
    • 参数是指定组别的索引
  • getChild
    • 相对应的,该方法返回指定组别中指定索引的子项数据
    • 参数是指定组别的索引以及指定子项的索引
  • getGroupId
    • 该方法类似BaseAdapter中的getItemId方法,可以获取指定索引的组别唯一标志
    • 参数是指定组别的索引
    • 一般不需要直接使用
  • getChildId
    • 相对应的,该方法返回子项的唯一标志
    • 参数是指定组别的索引以及指定子项的索引
  • hasStableIds
    • 该方法返回标志用于判断项目ID是否稳定,而如果确定ID稳定(这一般要求获取项目ID的方法返回唯一ID),则刷新List时会按照ID进行刷新,否则会按照项目显示的位置进行刷新
    • 一般不会直接使用
  • getGroupView
    • 该方法是主要的项目创建与修改方法,用于返回一个展示组别项目的视图
    • 可使用重用技巧减少开销
    • 参数是指定组别的索引,是否已经展开以及可以重用的视图
  • getChildView
    • 该方法是主要的子项创建与修改方法,用于返回一个展示子项的视图
    • 可使用重用技巧减少开销
    • 参数是指定组别的索引,指定子项的索引,是否最后一个子项以及可以重用的视图
  • isChildSelectable
    • 该方法指明子项是否可以选择,可选择的子项才可以响应点击事件
    • 一般不会直接使用,而是配合setOnChildClickListener方法为子项点击设置回调方法

  通常来说ExpandableListView适用于需要二级列表展开的需求上,比如通讯录,点击人物展开详细联系方式;又或者商品查询,点击商品展开详细参数等。
  使用ExpandableListView的例子如下所示,只要按需建立好数据结构,实现几个重要的接口方法,便可以成功展示出可展开列表了。

List<GroupItem> dataList;
class GroupItem {
    public String titleStr;
    public Integer iconRes;
    List<ChildItem> childItemList;
}
class ChildItem {
    public String content_1;
    public String content_2;
}
public class MyAdapter extends BaseExpandableListAdapter {
    @Override
    public int getGroupCount() {
        return dataList == null?0:dataList.size();
    }
    @Override
    public int getChildrenCount(int groupPosition) {
        return dataList.get(groupPosition).childItemList == null?0:dataList.get(groupPosition).childItemList.size();
    }
    @Override
    public Object getGroup(int groupPosition) {
        return dataList.get(groupPosition);
    }
    @Override
    public Object getChild(int groupPosition, int childPosition) {
        return dataList.get(groupPosition).childItemList.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 false;
    }
    @Override
    public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) {
        GroupHolder holder;
        if(convertView == null) {
            holder = new GroupHolder();
            convertView = LayoutInflater.from(resContext).inflate(R.layout.layout_exlv_group, null);
            holder.tvTitle = (TextView) convertView.findViewById(R.id.tvTitle);
            holder.ivIcon = (ImageView) convertView.findViewById(R.id.ivIcon);
            convertView.setTag(holder);
            convertView.setOnTouchListener(listTouchListener);
        } else {
            holder = (GroupHolder) convertView.getTag();
        }
        GroupItem item = (GroupItem) getGroup(groupPosition);
        if(item != null) {
            holder.tvTitle.setText(item.titleStr);
            holder.ivIcon.setImageResource(item.iconRes);
        }
        return convertView;
    }
    @Override
    public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) {
        ChildHolder holder;
        if(convertView == null) {
            holder = new ChildHolder();
            convertView = LayoutInflater.from(resContext).inflate(R.layout.layout_exlv_child, null);
            holder.tvContent_1 = (TextView) convertView.findViewById(R.id.tvContent_1);
            holder.tvContent_2 = (TextView) convertView.findViewById(R.id.tvContent_2);
            convertView.setTag(holder);
        } else {
            holder = (ChildHolder) convertView.getTag();
        }
        ChildItem item = (ChildItem) getChild(groupPosition, childPosition);
        if(item != null) {
            holder.tvContent_1.setText(item.content_1);
            holder.tvContent_2.setText((item.content_2));
        }
        return convertView;
    }
    @Override
    public boolean isChildSelectable(int groupPosition, int childPosition) {
        return false;
    }
    class GroupHolder {
        TextView tvTitle;
        ImageView ivIcon;
    }
    class ChildHolder {
        TextView tvContent_1;
        TextView tvContent_2;
    }
}

  对于ExpandableListView所使用的数据结构不必局限于自定义对象的列表嵌套,可以按需通过Map,List接口等实现自己想要使用的数据结构。


进一步使用ExpandableListView

  看起来ExpandableListView似乎只是单纯地拓展了ListView的功能,让列表项可以展开以展示更多层级的数据,实践中类似的需求都可以退化为多个页面的列表展示,甚至单个页面的列表数据切换,那么使用ExpandableListView的优势究竟在什么地方?
  答案是显而易见的,数据的聚合会减少页面切换或者数据切换的开销以及用户因获取信息的不连贯性而产生的消极情绪,简洁的数据和复杂的数据通过“展开与收起”这样的交互方式得以切换展示对用户的直观感受具有正面积极的作用。
  举个很简单的例子,一个简单的新闻列表,如果使用ListView实现,那么只能做成点击标题进入新页面显示内容,如果需求中不存在诸如评论等复杂功能的话,这样的交互会显得单调而且单独页面的信息聚合度不足。
  这时候使用ExpandableListView改造页面就能让用户体验变得更好,点击标题后并不会跳转新页面而是就地展开,显示内容。
  当然这个例子并不实际,现实中的新闻客户端都有非常复杂的信息聚合,功能也繁多,并不需要ExpandableListView这样的“小伎俩”,但这个小例子能提供一种思考的方式。
  在什么场景下需要可展开的列表,这不是一个有标准答案的问题,根据需求,用户群,展示效果等综合判断最后才能确定;但有一点是不会改变的,可展开列表对于增加页面信息聚合度和提升界面交互性有较强的正面作用,抓准这一点总没错。
  下面讲到的一个例子是作者实际参与的一个生产项目,已经上线了不短的时间,因为涉及商业项目所以不提具体的东西。
  需求大概如此:

  • 一个查询体育比赛的资料库页面
  • 资料库首页有数个标签,包括热门比赛以及大洲分类
  • 每个大洲有分洲际赛事和国家赛事
  • 国家页面展示国家,点击国家后展开显示当前国家的比赛
  • 点击比赛后进入比赛详情页面

设计框图大概如下
Figure1
  标签栏用于切换不同的大洲以及热门和国际页面,一级网格每行四个格子,主要显示热门赛事,洲际赛事,每个大洲的主要国家以及世界级赛事;二级网格则用于显示每个国家自己的赛事。
  看起来并不是很难,后台准备好数据,应用从接口获取,然后展示出来就行了,没有实时性需求也没有安全性需求,除了需要展示的数据可能会比较多之外没什么别的特点。
  在这个页面需求中,最需要关注的就是首页设计,按照设计图纸,要求首页以网格形式展示赛事和国家的图标,用按钮切换,总计六个标签页,分别展示热门赛事,四个大洲以及国际赛事。
  六个标签页显然是使用Fragment+ViewPager实现,但标签页内的网格型展示如何实现呢?首先想到的是GridView,它和ListView非常相似,只需要继承BaseAdapter实现相关方法即可。
  但是在这个情况下,不可以使用GridView来实现网格展示,因为需求中有点击展开的部分,GridView只能用来实现不存在展开的赛事列表展示,而需要展开的国家列表靠它无法解决问题。
  作者曾经尝试过使用属性动画手动控制GridView的每个Cell来强行实现展开,最后还是放弃了,不但难度很高,效果细节上也让人无法接受。
  自定义Layout是一个可能的解决之道,但自定义的话需要花费不少时间,而且可能出现的BUG也不少,对于一个有Deadline的商业项目而言应该尽量避免使用自定义Layout的情况,能用原生的组件是最好的。
  思来想去最后找到了ExpandableListView,从某种角度来说这是一次逆向思维,不再去考虑网格型展示界面怎么实现展开,而是反过来考虑可以展开的界面如何实现网格型展示,这样一想整个需求的难度下降了很多。
  思路很简单,使用ExpandableListView来模拟网格展示,因为设计图规定了父级网格每行四个元素,子级网格每行五个元素,那么使用ExpandableListView的话只需要准备一个拥有一行四个方格的父级Layout和一个一行五个方格的子级Layout,逻辑控制交由Adapter进行,在已知行数和列数的前提下用数学方法确定被点击的是哪个方格,对于多出来的方格则使用visibility=invisible属性来实现隐藏。
  最后这个需求问题就这样解决了,而且工作的很好,虽然调试过程中因为计算点击方格位置的数学过程不完善导致过BUG,但那只是支末细节罢了,整体功能毫无问题,直到今天线上项目依然使用的这套机制。
  通过这个例子,作者只是想说明一点,ExpandableListView表面上看起来只是个高级的ListView,但它能使用的场景多种多样,并不是说在需要展开的地方就一定要使用ExpandableListView,也不是说看起来不像列表的地方就用不着ExpandableListView,这些组件的使用可以是非常灵活的,并不需要遵循什么准则,只要能成功实现功能并且通过测试,如何使用组件并不重要。


ExpandableListView使用小技巧

  ExpandableListView的使用有不少技巧,作者所知仅有皮毛,在此分享。

  • ExpandableListView的分组点击回调
      默认情况下ExpandableListView的每个分组点击时都会展开其子级,根据Adapter中提供的getChildView方法来展示子级数据。但是在某些时候这个默认逻辑并不适用,有的需求中需要父级满足一定条件时才展开,否则不予展开;有的需求要进入某种特定状态后才能展开等等。这个时候就需要重新自定义分组点击回调方法了。
      方法非常简单,只需要重设onGroupClickListener即可,在自定义的Listener中按照需求处理。同时,ExpandableListView还允许用户自行设置onGroupCollapseListener监听以及onGroupExpanListener监听,这样能适应更加细致和复杂的需求。
exlvMain.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() {
    @Override
    public boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id) {
        // 可以随意根据需求自定义行为,注意返回值为false表示不展开子项,为true才是展开
        return false;
    }
});
  • ExpandableListView的分组标题浮动
      一般来说ExpandableListView和ListView没有太大的区别,但既然ExpandableListView有了分组,那么每一组的标题栏就会有不同,以之前说过的新闻页面为例,在那种情况下标题是新闻标题;如果换成之前说的资料库页面,标题就是四个方格。
      考虑这样一种需求,一个备忘录,备忘按照日期分组,每个组的备忘分别展示,要求展示到一张表上。进入页面时全部日期标题展开,可以点击收起,滑动列表时日期标题常驻顶部。
      Figure2
      这个需求听起来感觉熟悉,事实上很多线上项目就有类似的实现,列表滑动时总有个表头一样的东西浮动在顶端,还会根据滑动到的位置发生变化。有这种浮动标题栏当然就会比什么都没有的列表要对用户友好得多,毕竟当列表庞大的时候没有人喜欢往回翻老长一段去看表头或者标题是什么。
      要实现这个需求,乍一看觉得不容易,但实际上ExpandableListView提供了一些可以帮助实现的方法,下面就分析一下如何实现。
      首先需要一个在列表滑动时展示的标题栏,要和列表中的标题栏完全相同,和列表本身放在同一个FrameLayout下,理好顺序让标题栏盖住列表。
<FrameLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ExpandableListView
        android:id="@+id/exlvMain"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
    </ExpandableListView>
    <LinearLayout
        android:id="@+id/vGroupIndicator"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <TextView
            android:id="@+id/tvTitleIndicator"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            />
    </LinearLayout>
</FrameLayout>

  大概如上所示,ID为vGroupIndicator的部分就是盖在列表上的标题栏。
  随后自定义ExpandableListView,只需要简单地继承ExpandableListView然后重写和添加一些方法即可。

public class GroupIndicatorExpandableListView 
    extends ExpandableListView
    implements AbsListView.OnScrollListener {

    public GroupIndicatorExpandableListView(Context context) {
        super(context);
    }
    public GroupIndicatorExpandableListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    public GroupIndicatorExpandableListView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public void onScrollStateChanged(AbsListView view, int scrollState) {
        //
    }

    // 该接口回调方法就是用于实现浮动标题栏的
    private OnGroupIndicatorShowListener onGroupIndicatorShowListener;

    public void setOnGroupIndicatorShowListener(OnGroupIndicatorShowListener onGroupIndicatorShowListener) {
        setOnScrollListener(this);
        this.onGroupIndicatorShowListener = onGroupIndicatorShowListener;
    }

    // 该方法的重载实现是重点
    @Override
    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
                         int totalItemCount) {
        boolean show;
        long listPosition = getExpandableListPosition(firstVisibleItem);
        // 当前第一行归属的组ID getPackedPositionGroup 返回所选择的组
        int groupId = getPackedPositionGroup(listPosition);
        // 当前第一行的子视图类型
        int viewType = getPackedPositionType(listPosition);
        // 当前第二行的子视图类型
        int nextViewType = getPackedPositionType(getExpandableListPosition(
                firstVisibleItem + 1));
        if((viewType == PACKED_POSITION_TYPE_NULL
            && nextViewType == PACKED_POSITION_TYPE_NULL)
                ||(viewType == PACKED_POSITION_TYPE_NULL
                   && nextViewType == PACKED_POSITION_TYPE_GROUP)) {
            show = false;
        } else if(viewType == PACKED_POSITION_TYPE_CHILD
                  && nextViewType == PACKED_POSITION_TYPE_GROUP) {
            show = false;
        } else {
            show = true;
        }
        if(onGroupIndicatorShowListener != null) {
            onGroupIndicatorShowListener.OnGroupIndicatorShow(!canPullDown() && show, groupId);
        }
    }

    private boolean canPullDown() {
        return getCount() == 0
               ||getFirstVisiblePosition() == 0
                 && getChildAt(0).getTop() >= 0;
    }

    public interface OnGroupIndicatorShowListener {
        void OnGroupIndicatorShow(boolean show, int groupId);
    }
}

  通过在onScroll方法中计算当前第一个显示的是何种项目来决定是否需要显示标题栏,只要按需编写onGroupIndicatorShowListener的实现类即可。

indicator = findViewById(R.id.vGroupIndicator);
tvIndicator = (TextView) findViewById(R.id.tvTitleIndicator);
exlvMain = (GroupIndicatorExpandableListView) findViewById(R.id.exlvMain);
exlvMain.setOnGroupIndicatorShowListener(new GroupIndicatorExpandableListView
        .OnGroupIndicatorShowListener() {
    @Override
    public void OnGroupIndicatorShow(boolean show, final int groupId) {
        if(groupId >= 0) {
            updateIndicator(groupId);
        } else {
            show = false;
        }
        indicator.setVisibility(show?View.VISIBLE:View.GONE);
        indicator.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                try {
                    if(exlvMain.isGroupExpanded(groupId)) {
                        exlvMain.collapseGroup(groupId);
                    } else {
                        exlvMain.expandGroup(groupId);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });
    }
});

  其中的辅助方法updateIndicator用于更新浮动标题栏上显示的文字或图标等信息

private void updateIndicator(int groupPosiion) {
    if(dataList != null && groupPosiion < dataList.size()) {
        GroupItem grp = dataList.get(groupPosiion);
        tvIndicator.setText(grp.titleStr);
    }
}

  至此这个浮动标题栏就可以使用了。
  ExpandableListView作为安卓系统的一种重要组件,其使用方式灵活多变,而且根据它拓展出来的第三方开源库也有不少,有些加强了动画效果,还有些拓展了功能,这些对于实际生产环境中的应用开发是有很大帮助的。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值