Android自定义控件——仿饿了么联动ListView
前几天,群里一哥们儿私聊我,问我会不会二级联动,当时的我是一脸懵逼啊,曾经听人提起过,但是自己也没用过,也没尝试着去做,正好趁这个机会就学学呗,Demo还是这哥们儿给我的呢,诺,github链接:DoubleListViewLinkage,简书链接:羊皮书APP(Android版)开发系列(二十一)双联动分组ListView,类似于外卖点餐,但是很头疼的,一个Android小白,要看没有一行注释的代码,Oh My God!不多说了,开车吧~
我们先来看下效果哈,然后来分析是怎么实现的,如下图:
看到后,或许会感到一头雾水,首先,标题是怎么变的,然后左边的item又是怎么变的,然后我们的自定义到底在哪儿?
ListView的自定义是哪一块儿?
一开始我也不知道ListView的自定义,到底是自定义的哪一块儿,毕竟这个概念是比较重要的,因为既然我们都要自定义ListView了,但是不知道自定义哪里,岂不是很尴尬?我们先来一张静态的图哈,来看看到底是哪里需要自定义如下图:
再来看下自定义后的ListView,如下图:
右边的是哈,然后我们可以发现他们的Item是不同的,所以说,自定义ListView其实就是自定义Item,然后我们来分析下哈,自定义Item,说到底,要想实现这个效果的自定义Item就是加了一个头部,也就是标题啦,然后我们看效果图的时候,可以发现当第一个标题内的内容向上移动,消失的时候,那个标题也就消失了,所以我们还要实现这个随着标题内的最后一个内容消失的时候,该标题也要消失。
总结一下呢,我们自定义Item要完成的就是,“标题+内容”,从开始出现到消失,且显示第二个“标题+内容”的过程。
那就具体的来实现吧!
在上一环节我们分析了,到底要自定义哪里,且是怎样的一个过程,那么这一环节我们就来再深入一点儿哈,我们要自定义ListView那么肯定是要继承ListView的啦,况且我们要监听一下内容是什么时候消失的,那么我们就必须要实现AbsListView.OnScrollListener这个接口喽~然后alt+回车,把抽象方法都实现,还要实现那必须的三个构造方法哈,最终如下:
public class HaveHeaderListView extends ListView implements AbsListView.OnScrollListener {
public HaveHeaderListView(Context context) {
super(context);
}
public HaveHeaderListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public HaveHeaderListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
}
}
然后我们在构造方法中super下那个滑动监听哈。就是这句super.setOnScrollListener(this);
这样完事儿后,我们来写一下我们的这个Adapter,因为这个属于我们自定义的了,若是想以前那样写Adapter是肯定不可以的,所以来写下我们自己的Adapter吧。Adapter代码如下:
public interface HaveHeaderAdapter {
boolean isSectionHeader(int position);
int getSectionForPosition(int position);
View getSectionHeaderView(int section, View convertView, ViewGroup parent);
int getSectionHeaderViewType(int section);
int getCount();
}
这个Adapter其实就是和我们的那个标题相对应的,看名字大家应该都知道,也就是仿着我们的那个BaseAdapter写的。然后我们需要几个变量,如下:
private HaveHeaderAdapter mAdapter;
//标题
private View mCurrentHeader;
//默认显示第几个标题
private int mCurrentHeaderViewType = 0;
//标题距顶部的距离
private float mHeaderOffset;
//是否显示
private boolean mShouldPin = true;
//当前部分
private int mCurrentSection = 0;
//宽度
private int mWidthMode;
//高度
private int mHeightMode;
注释已经说明了哈,这里也就不啰嗦了,嘿嘿。
OK,所用的变量都有了,那么我们就来实现吧,既然是通过向上滑动和向下滑动来让mCurrentHeader(也就是标题既然这里我们都有相应的变量了,那么我们就用它在代码中真实的名字吧!)显示和隐藏的,那么主要逻辑和代码实现肯定是在onScroll里面了,先贴代码:
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
if (mOnScrollListener != null) {
mOnScrollListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount);
}
if (mAdapter == null || mAdapter.getCount() == 0 || !mShouldPin) {
//当适配器为空或适配器中无数据或mShouldPin为false或者可见视同中第一个索引小于0则return
return;
}
//根据可见视图的第一个索引去获取section
int section = mAdapter.getSectionForPosition(firstVisibleItem);
//根据获取到的section去获取viewType
int viewType = mAdapter.getSectionHeaderViewType(section);
//获取标题
mCurrentHeader = getSectionHeaderView(section, mCurrentHeader);
//更换标题
ensureHaveHeaderLayout(mCurrentHeader);
//改成当前标题所对应的值
mCurrentHeaderViewType = viewType;
//设置标题距顶部距离
mHeaderOffset = 0.0f;
for (int i = firstVisibleItem; i < firstVisibleItem + visibleItemCount; i++) {
if (mAdapter.isSectionHeader(i)) {
//得到真实的子Item的值
View ChildView = getChildAt(i - firstVisibleItem);
//得到子Item距顶部的距离
float ChildViewTop = ChildView.getTop();
//得到子Item的高度
float ChildViewHeight = ChildView.getMeasuredHeight();
//将子Item设置为显示
ChildView.setVisibility(VISIBLE);
if (ChildViewHeight >= ChildViewTop && ChildViewTop > 0) {
//当子Item的高度>子Item距顶部的距离时,则标题应该逐步消失
mHeaderOffset = ChildViewTop - ChildViewHeight;
} else if (ChildViewTop <= 0) {
//子Item距离小于0则将头部设置为不显示
ChildView.setVisibility(INVISIBLE);
}
}
}
//刷新
invalidate();
}
先来解释下firstVisibleItem,visibleItemCount,totalItemCount这三个变量是什么意思哈,挺重要的。
firstVisibleItem,官方文档是这样写的:int: the index of the first visible cell (ignore if visibleItemCount == 0)
由于本人英语渣渣,经过不靠谱的有道翻译,再加上自己打log试,大致懂了,它其实就是可见View中的第一个索引,也就是在可见View中的第一个视图的索引值,再用下图来解释下,如下:
在该图中的firstVisibleItem就是“面食类”的索引值,它的索引就是0了,所以firstVisibleItem就是0了。
visibleItemCount,这个值想半天想不懂,然后经过刘某人的指点懂了,哈哈,就这个界面log值出来的和我数的值总是差1(我数的少),很纳闷儿,因为我们都知道计算机计数都是从0开始的,但是我若是从0开始数(面食类算第0个元素)就和log值出来的少1了,问刘某人后,老刘说最上面的那个也算,也就是说,visibleItemCount计数是从最上面的那个ListViewLinkage开始计的,恍然大悟啊~
totalItemCount,就简单了totalItemCount = firstVisibleItem + visibleItemCount;
然后剩下的……就是代码注释的那样了…
getSectionHeaderView()代码如下:
private View getSectionHeaderView(int section, View oldView) {
//是否显示,即,section不等于当前显示的section,且View不为空
boolean shouldLayout = section != mCurrentSection || oldView == null;
//获取View
View view = mAdapter.getSectionHeaderView(section, oldView, this);
if (shouldLayout) {
//显示标头
ensureHaveHeaderLayout(view);
//并将section赋值给mCurrentSection
mCurrentSection = section;
}
//返回加载好的View
return view;
}
ensureHaveHeaderLayout()代码如下:
private void ensureHaveHeaderLayout(View header) {
if (header.isLayoutRequested()) {
//设置宽(返回值是测量值+mode值)
int widthSpec = MeasureSpec.makeMeasureSpec(getMeasuredWidth(), mWidthMode);
int heightSpec;
//父布局参数
ViewGroup.LayoutParams layoutParams = header.getLayoutParams();
if (layoutParams != null && layoutParams.height > 0) {
//若有父布局则header高为父布局的
heightSpec = MeasureSpec.makeMeasureSpec(layoutParams.height, MeasureSpec.EXACTLY);
} else {
//否则,header高为自适应大小
heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
}
//设置header宽高
header.measure(widthSpec, heightSpec);
//设置header相对于父布局的位置,左,上,右,下
header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight());
}
}
只有这样还是不行的,虽然这里的逻辑有了,但是最重要的绘制还没有呢,重写dispatchDraw()方法,代码如下:
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if (mAdapter == null || !mShouldPin || mCurrentHeader == null) {
//adapter为空,mShouldPin为false,mCurrentHeader为空,则不绘制
return;
}
//保存Canvas状态
int saveCount = canvas.save();
//平移
canvas.translate(0, mHeaderOffset);
//设置显示范围,左,上,右,下
canvas.clipRect(0, 0, getWidth(), mCurrentHeader.getMeasuredHeight());
mCurrentHeader.draw(canvas);
//恢复Canvas状态
canvas.restoreToCount(saveCount);
}
同样,注释都写上了……
自定义控件怎么能少的了测量呢,重写onMeasure()方法,代码如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//宽
mWidthMode = MeasureSpec.getMode(widthMeasureSpec);
//高
mHeightMode = MeasureSpec.getMode(heightMeasureSpec);
}
当然,setAdapter()方法也要重写,代码如下:
public void setAdapter(ListAdapter adapter) {
mCurrentHeader = null;
mAdapter = (HaveHeaderAdapter) adapter;
super.setAdapter(adapter);
}
由于现在的点击事件不同了,所以点击事件的代码如下:
public static abstract class OnItemClickListener implements AdapterView.OnItemClickListener {
@Override
public void onItemClick(AdapterView<?> parent, View view, int rawPosition, long id) {
CustomizeLVBaseAdapter adapter;
if (parent.getAdapter().getClass().equals(HeaderViewListAdapter.class)) {
HeaderViewListAdapter wrapperAdapter = (HeaderViewListAdapter) parent.getAdapter();
adapter = (CustomizeLVBaseAdapter) wrapperAdapter.getWrappedAdapter();
} else {
adapter = (CustomizeLVBaseAdapter) parent.getAdapter();
}
int section = adapter.getSectionForPosition(rawPosition);
int position = adapter.getPositionInSectionForPosition(rawPosition);
if (position == -1) {
onSectionClick(parent, view, section, id);
} else {
onItemClick(parent, view, section, position, id);
}
}
public abstract void onItemClick(AdapterView<?> adapterView, View view, int section, int position, long id);
public abstract void onSectionClick(AdapterView<?> adapterView, View view, int section, long id);
}
最后该自定义ListView的完整代码如下: