青春须早为,岂能长少年。这篇文章主要讲述Android自定义ViewGroup——带悬停标题的ExpandableListView相关的知识,希望能为你提供帮助。
项目里要加一个点击可收缩展开的列表,
要求带悬停标题,
具体效果如下图:
文章图片
也就是说, 在某一个分组内部滚动时, 要求分组标题悬停, 当滚出该分组范围时, 把标题顶出去, 悬停下一个分组的标题。正好看到一个比较有趣的思路, 做了一个实现, 在这里分享一下。代码结构如下, 基本上是一个MVC的架构:
文章图片
【Android自定义ViewGroup——带悬停标题的ExpandableListView】既然是点击可收缩展开的列表, 显然要用ExpandableListView, 关于这个类的用法这里就不赘述了, 网上一搜一大把, 其实跟ListView的用法差不多, 不过它帮你分了组, 所以原来Adapter里的getView()就变成了getGroupView()和getChildView(), getCount()就变成了getGroupCount()等等。另外既然要支持收缩展开, 必然会提供collapseGroup()和expandGroup()等接口。
下面分析如何添加悬停标题, 其实精华部分就一句话: 悬停标题是画上去的, 而不是加到view hierarchy里去, 具体根据滚动的情况确定如何画。
首先我们来写一个DockingExapandableListView类, 继承自ExpandableListView, 包含一个View类型的成员变量mDockingHeader。
一、重写onMeasure()和onLayout()方法
@
Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (mDockingHeader !=
null) {
measureChild(mDockingHeader, widthMeasureSpec, heightMeasureSpec);
mDockingHeaderWidth =
mDockingHeader.getMeasuredWidth();
mDockingHeaderHeight =
mDockingHeader.getMeasuredHeight();
}
}@
Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mDockingHeader !=
null) {
mDockingHeader.layout(0, 0, mDockingHeaderWidth, mDockingHeaderHeight);
}
}
这个比较简单, 就是测量一下这个标题视图的宽度和高度。
二、重写dispatchDraw()方法 上面提到, 悬停标题是画上去的, 而不是加到view hierarchy里去的。因此, 需要在完成其他子view的绘制之后, 再把悬停标题栏画上去:
@
Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if (mDockingHeaderVisible) {
// draw header view instead of adding into view hierarchy
drawChild(canvas, mDockingHeader, getDrawingTime());
}
}
三、根据滚动状态决定如何绘制悬停标题
滚动到不同位置, 悬停标题的显示是不同的, 因此需要根据滚动状态定义一个状态机的切换。让DockingExpandableListView实现OnScrollListener接口, 并重写onScroll()方法:
@
Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
long packedPosition =
getExpandableListPosition(firstVisibleItem);
int groupPosition =
getPackedPositionGroup(packedPosition);
int childPosition =
getPackedPositionChild(packedPosition);
// update header view based on first visible item
// IMPORTANT: refer to getPackedPositionChild():
// If this group does not contain a child, returns -1. Need to handle this case in controller.
updateDockingHeader(groupPosition, childPosition);
}
这里有几个比较有意思的方法, 都是ExpandableListView自带的API:
getExpandableListPosition(): 这个API获得一个所谓的packed position, 是一个64位的值, 高32位表示group的ID, 低32位表示在这个group内部的child ID。
getPackedPositionGroup(): 获取group ID, 也就是高32位
getPackedPositionChild(): 获取child ID, 也就是低32位
注意我们给getExpandableListPosition()传的参数是firstVisibleItem, 因此我们就得到了最上方的第一个可见项所属的group以及组内位置。接下来就是最为关键的updateDockingHeader()方法, 根据状态机来确定如何绘制悬停标题。在看这个方法之前, 我们先看一下有哪几种状态, 定义在IDockingController里:
public interface IDockingController {
int DOCKING_HEADER_HIDDEN =
1;
int DOCKING_HEADER_DOCKING =
2;
int DOCKING_HEADER_DOCKED =
3;
int getDockingState(int firstVisibleGroup, int firstVisibleChild);
}
一共3 种状态, 这些状态都是什么含义呢? 参见下图:
文章图片
DOCKING_HEADER_HIDDEN: 当分组没有展开, 或者组里没有子项的时候, 是不需要绘制悬停标题的
DOCKING_HEADER_DOCKING: 当滚动到上一个分组的最后一个子项时, 需要把旧的标题“推”出去, “停靠”新的标题, 所以这个状态命名为“docking”
DOCKING_HEADER_DOCKED: 新标题“停靠”完毕, 在该分组内部滚动, 称为“docked”状态
基于这个状态机, 我们来看一下updateDockingHeader()方法的实现:
private void updateDockingHeader(int groupPosition, int childPosition) {
if (getExpandableListAdapter() =
=
null) {
return;
}if (getExpandableListAdapter() instanceof IDockingController) {
IDockingController dockingController =
(IDockingController)getExpandableListAdapter();
mDockingHeaderState =
dockingController.getDockingState(groupPosition, childPosition);
switch (mDockingHeaderState) {
case IDockingController.DOCKING_HEADER_HIDDEN:
mDockingHeaderVisible =
false;
break;
case IDockingController.DOCKING_HEADER_DOCKED:
if (mListener !=
null) {
mListener.onUpdate(mDockingHeader, groupPosition, isGroupExpanded(groupPosition));
}
// Header view might be "
GONE"
status at the beginning, so we might not be able
// to get its width and height during initial measure procedure.
// Do manual measure and layout operations here.
mDockingHeader.measure(
MeasureSpec.makeMeasureSpec(mDockingHeaderWidth, MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec(mDockingHeaderHeight, MeasureSpec.AT_MOST));
mDockingHeader.layout(0, 0, mDockingHeaderWidth, mDockingHeaderHeight);
mDockingHeaderVisible =
true;
break;
case IDockingController.DOCKING_HEADER_DOCKING:
if (mListener !=
null) {
mListener.onUpdate(mDockingHeader, groupPosition, isGroupExpanded(groupPosition));
}View firstVisibleView =
getChildAt(0);
int yOffset;
if (firstVisibleView.getBottom() <
mDockingHeaderHeight) {
yOffset =
firstVisibleView.getBottom() - mDockingHeaderHeight;
} else {
yOffset =
0;
}// The yOffset is always non-positive. When a new header view is "
docking"
,
// previous header view need to be "
scrolled over"
. Thus we need to draw the
// old header view based on last child'
s scroll amount.
mDockingHeader.measure(
MeasureSpec.makeMeasureSpec(mDockingHeaderWidth, MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec(mDockingHeaderHeight, MeasureSpec.AT_MOST));
mDockingHeader.layout(0, yOffset, mDockingHeaderWidth, mDockingHeaderHeight +
yOffset);
mDockingHeaderVisible =
true;
break;
}
}
}
其中, 是否显示悬停标题是通过一个叫做mDockingHeaderVisible的boolean变量控制的, 这个在上面的dispatchDraw()方法里也见到了。
重点看“docking”状态的处理: 通过计算第一个可见项的bottom和高度之间的差异, 也就是这个yOffset, 确定悬停标题在y轴方向的偏移量。这样在绘制悬停标题的时候, 我们就只能看到一部分, 造成一种被“推出去”的感觉。
四、悬停标题状态机 在刚刚提到的那个IDockingController接口里有一个方法叫getDockingState(), 在updateDockingHeader()方法里就是通过调用这个方法来确定当前悬停标题的状态的。DockingExpandableListViewAdapter实现了该接口和方法, 完成状态机状态转换:
@
Override
public int getDockingState(int firstVisibleGroup, int firstVisibleChild) {
// No need to draw header view if this group does not contain any child &
also not expanded.
if (firstVisibleChild =
=
-1 &
&
!mListView.isGroupExpanded(firstVisibleGroup)) {
return DOCKING_HEADER_HIDDEN;
}// Reaching current group'
s last child, preparing for docking next group header.
if (firstVisibleChild =
=
getChildrenCount(firstVisibleGroup) - 1) {
return IDockingController.DOCKING_HEADER_DOCKING;
}// Scrolling inside current group, header view is docked.
return IDockingController.DOCKING_HEADER_DOCKED;
}
逻辑非常简单清晰:
如果当前group没有子项, 并且也不是展开状态, 就返回DOCKING_HEADER_HIDDEN状态, 不绘制悬停标题;
如果到达了当前group的最后一个子项, 进入DOCKING_HEADER_DOCKING状态;
其他情况, 在当前group内部滚动, 返回DOCKING_HEADER_DOCKED状态。
五、Touch事件处理 文章最前面提到过, 这个标题视图是画上去, 而不是添加到view hierarchy里的, 因此它是无法响应touch事件的! 那就需要我们自己根据点击区域进行判断了, 需要重写onInterceptTouchEvent()和onTouchEvent()方法:
@
Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getAction() =
=
MotionEvent.ACTION_DOWN &
&
mDockingHeaderVisible) {
Rect rect =
new Rect();
mDockingHeader.getDrawingRect(rect);
if (rect.contains((int)ev.getX(), (int)ev.getY())
&
&
mDockingHeaderState =
=
IDockingController.DOCKING_HEADER_DOCKED) {
// Hit header view area, intercept the touch event
return true;
}
}return super.onInterceptTouchEvent(ev);
}// Note: As header view is drawn to the canvas instead of adding into view hierarchy,
// it'
s useless to set its touch or click event listener. Need to handle these input
// events carefully by ourselves.
@
Override
public boolean onTouchEvent(MotionEvent ev) {
if (mDockingHeaderVisible) {
Rect rect =
new Rect();
mDockingHeader.getDrawingRect(rect);
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
if (rect.contains((int)ev.getX(), (int)ev.getY())) {
// forbid event handling by list view'
s item
return true;
}
break;
case MotionEvent.ACTION_UP:
long flatPostion =
getExpandableListPosition(getFirstVisiblePosition());
int groupPos =
ExpandableListView.getPackedPositionGroup(flatPostion);
if (rect.contains((int)ev.getX(), (int)ev.getY()) &
&
mDockingHeaderState =
=
IDockingController.DOCKING_HEADER_DOCKED) {
// handle header view click event (do group expansion &
collapse)
if (isGroupExpanded(groupPos)) {
collapseGroup(groupPos);
} else {
expandGroup(groupPos);
}
return true;
}
break;
}
}return super.onTouchEvent(ev);
}
这部分实现比较简单易懂, 如果当前是DOCKING_HEADER_DOCKED状态, 并且点击区域命中了标题视图的drawing rect, 那么就需要拦截touch事件, 并且在手指抬起时根据group当前的状态执行收起或者展开的动作。
六、更新标题视图内容 前面5步已经完成了悬停标题状态机的控制, 但是具体标题栏上应该怎么显示( 比如变更标题文字、显示收缩展开图标等等) , 需要用户来处理。因此定义了一个IDockingHeaderUpdateListener接口, 用户需要实现onUpdate()方法, 根据当前的group ID以及收缩展开状态决定如何更新悬停标题视图:
public interface IDockingHeaderUpdateListener {
void onUpdate(View headerView, int groupPosition, boolean expanded);
}
在demo该方法的实现就是简单的更新悬停标题栏的文字, 具体参见MainActivity。
七、Adapter的数据源 这部分其实就是给DockingExpandableListViewAdapter又封了一层adapter, 因为有些方法实现过了, 就把那些需要用户提供数据的方法单独拎出来封了一个IDockingAdapterDataSource接口。当然你也可以不用这个接口直接改Adapter, 出于介绍的完整性考虑把接口贴在这里:
public interface IDockingAdapterDataSource {
int getGroupCount();
int getChildCount(int groupPosition);
Object getGroup(int groupPosition);
Object getChild(int groupPosition, int childPosition);
View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent);
View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent);
}
最后, 也是最重要的部分, 源码下载地址: 示例代码下载 (CSDN)
https://github.com/qianxin2016/DockingExpandableListView
推荐阅读
- android自动化测试--appium运行的坑问题及解决方法
- Android适配难题全面总结
- Android 6.0 权限请求
- Android逆向工程
- Android客户端性能优化(魅族资深工程师毫无保留奉献)
- Android中使用GridView和ImageViewSwitcher实现电子相册简单功能
- CSS如何使用#id选择器(代码实例)
- C/C++中如何使用多维数组(解析和用法示例)
- PHP如何使用Gmagick chopimage()函数(代码实例)