by Junda.Huang
工作之后,一直在开发Android手机系统自带的联系人应用。在开发联系人应用时,遇到了很多的困难。其实并没有很大的技术难点,但是很多不懂的简单知识点聚集在一起时,就让简单的问题变得复杂了。所以下决心对开发联系人应用过程中遇到的一些基础知识点进行归纳总结。
第一个基础知识点:PinnedHeaderListView的实现。
PinnedHeaderListView的应用很广:联系人的列表界面,音乐播放界面(如下图),以及天气的添加城市列表的界面等等。绝大部分应用的列表界面都会使用这种控件。
先分析一下上图中的ListView与普通的ListView有何不同,主要有两点:
1.列表中的item被header分成了多个不同区域,每个区域的相似之处:以相同字母开头的歌曲。
2.当滑动时,会有一个header被钉在列表的顶部。这也就是PinnedHeaderListView名字的由来。
接下来分别讨论以上两个功能的实现:
第一个功能的实现方式有两种:第一种,列表中的每一个item都包含header,只有每个区域中的第一个item的header部分显示出来。第二种,在每个区域的第一个item前面,添加一个header。
第一种实现方式中,如果header的布局比较复杂的话,对ListView的效率影响较大,所以采用第二种实现方式。第二种实现方式,本质就是在ListView中显示不同布局的item。直接上代码:
package com.example.differentitemdemo;
import java.util.ArrayList; import java.util.TreeSet; import android.app.ListActivity; import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.TextView;
public class MainActivity extends ListActivity { private CustomAdapter mAdapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mAdapter = new CustomAdapter(); for (int i = 1; i < 50; i++) { mAdapter.addItem("item: " + i); if (i != 0 && i % 4 == 0) { mAdapter.addSeparator("separator: " + i/4); } } setListAdapter(mAdapter); } private class CustomAdapter extends BaseAdapter { private final int TYPE_GENERAL_ITEM = 0; private final int TYPE_SEPARATOR_ITEM = 1; LayoutInflater inflater; private ArrayList<String> mItemData = new ArrayList<String>(); private TreeSet<Integer> mSeparators = new TreeSet<Integer>(); public void addItem(final String item) { mItemData.add(item); notifyDataSetChanged(); } public void addSeparator(final String separatorItem) { mItemData.add(separatorItem); mSeparators.add(mItemData.size() - 1); notifyDataSetChanged(); } public CustomAdapter() { inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); } @Override public int getItemViewType(int position) { return mSeparators.contains(position) ? TYPE_SEPARATOR_ITEM : TYPE_GENERAL_ITEM; }; @Override public int getViewTypeCount() { return 2; } @Override public int getCount() { return mItemData.size(); } @Override public Object getItem(int position) { return mItemData.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder = null; int type = getType(position); if (convertView == null) { holder = new ViewHolder(); switch (type) { case TYPE_GENERAL_ITEM: convertView = inflater.inflate(R.layout.general_item, null); break; case TYPE_SEPARATOR_ITEM: convertView = inflater.inflate(R.layout.separator_item, null); break; default: break; } holder.textView = (TextView) convertView.findViewById(R.id.textview); convertView.setTag(holder); } holder = (ViewHolder) convertView.getTag(); holder.textView.setText(mItemData.get(position)); return convertView; } public class ViewHolder { public TextView textView; } } } |
通过以上代码,可以发现,主要的功能的实现都在于CustomAdapter中。
CustomAdapter继承自BaseAdapter,自定义BaseAdapter的实现如果不清楚,可以参考这篇文章http://blog.csdn.net/listening_music/article/details/6965755。
与一般的自定义BaseAdapter相比,在CustomAdapter中我们多重写了两个方法:getItemViewType(intposition),以及getViewTypeCount()。
需要重写这两个方法一方面是因为我们的ListView中包含两种不同item;另一方面是因为我们在getView(int position, ViewconvertView, ViewGroup parent)方法中,复用了被ListView回收的item,而不是每次都重新从布局文件加载。(如果不复用被回收的item,也可以不重写这两个方法,但是从效率方面考虑不推荐)。
接下来,简单分析ListView如何复用被回收的item,解释重写这两个方法的必要性。ListView中需要显示一个新的item时,就会调用AbsListView.obtainView()方法(AbsListView()是ListView父类),AbsListview会向RecycleBin请求一个scrapView,这个RecycleBin是listview里面的一个重要机制。简单来说,就是它缓存了那些不在屏幕内的ListView的item,相当于一个垃圾箱。然后当有新的item需要显示的时候,它会首先向垃圾箱里面请求一个已经不显示的item,如果有这样的item的话,就直接拿过来使用。如果没有这样的item才会去创建一个item view。滑动的时候,它会不断地把滑出屏幕的item添加到RecycleBin这个垃圾箱里面。这样就实现了一个循环,ListView中不管有多少数据,不管滑动多少次,被回收的item和正在显示的item形成一个链条结构,不断回收,不断复用。
我们看看mRecycler.getScrapView(position)的实现:
/** * @return A view from the ScrapViews collection. These are unordered. */ View getScrapView(int position) { ArrayList<View> scrapViews; if (mViewTypeCount == 1) { scrapViews = mCurrentScrap; int size = scrapViews.size(); if (size > 0) { return scrapViews.remove(size - 1); } else { return null; } } else { int whichScrap = mAdapter.getItemViewType(position); if (whichScrap >= 0 && whichScrap < mScrapViews.length) { scrapViews = mScrapViews[whichScrap]; int size = scrapViews.size(); if (size > 0) { return scrapViews.remove(size - 1); } } } return null; } |
其中的mViewTypeCount就是Adapter的getViewTypeCount()方法返回的,如果没有重写的话,默认实现就是返回1。在ListView.setAdapter()方法中,为mViewTypeCount赋值:
public void setAdapter(ListAdapter adapter) { super.setAdapter(adapter); if (mAdapter != null){ … mRecycler.setViewTypeCount(mAdapter.getViewTypeCount()); … } } |
如果mViewTypeCount == 1的话,也就是ListView中只有一种类型的item,那么直接从mCurrentScrap里面获取即可。如果有多个类型的item的话,首先,要调用我们重写的getItemViewType(intposition)来获取到这种类型的item的索引号。
int whichScrap =mAdapter.getItemViewType(position);
然后根据这个索引号whichScrap,从mScrapView数组里面获取到对应的垃圾箱,然后再从这个垃圾箱里面去获取属于whichScrap这个类型的被回收的item。这样可以避免了复用错误的问题:比如header类型的item复用了缓存中普通类型的item。
第二个功能,其实就是通过监听ListView的滑动事件,在ListView的顶部显示一个被定住的header:实际实现过程中,是将pinned header view绘制在ListView的顶部,而不是将pinned header view作为一个child添加到ListView中去。
(PinnedHeaderListView源码:https://github.com/JimiSmith/PinnedHeaderListView)
为了监听滑动事件,PinnedHeaderListView实现了OnScrollListener接口,并对构造函数以及setOnScrollListener方法进行如下改造:
public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); super.setOnScrollListener(this); } -------------------分割线--------------------------- @Override public void setOnScrollListener(OnScrollListener onScrollListener) { mOnScrollListener = onScrollListener; super.setOnScrollListener(this); } |
上述setOnScrollListener的实现使得PinnedHeaderListView在实现pinned header view功能的同时,还可以响应外部设置的onScrollListener相关事件。
接下类,我们分析一下PinnedHeaderListView源码中的onScroll()方法。
为了避免讲述不清,先定义一下概念:
被定在ListView顶部的view我们称作:pinned header view。
ListView本身通过setHeaderView()设置的view我们称作:header。
ListView列表中的header类型的item,我们称作:headeritem。
如果ListView是空,或者我们设置了ListView不要pinned header效果,或者ListView有设置setHeaderView(),并且header尚未完全滑出屏幕外,那么执行下面这段代码:把pinned headerview以及offset置空,然后遍历所有子view设置为visible的状态,因为当某个header item滑出屏幕顶部时,我们会把它设为invisible。
if (mAdatpter ==null || mAdapter.getCount() == 0 !mShouldPin || (firstVisibleItem < getHeaderViewsCount())) { mCurrentHeader = null; mHeaderOffset = 0.0f; for (int i = firstVisibleItem; i < firstVisibleItem + visibleItemCount; i++) { View header = getChildAt(i - firstVisibleItem); if (header != null) { header.setVisibility(VISIBLE); } } return; } |
然后计算除去ListView原始header之外,第一个可见的item的位置(此时header如果存在,也已经滑出屏幕顶部),也就是当前可见的第一个item的position减去header个数。根据这个item的实际position来计算其属于哪个区域(section),具体的实现在SectionBaseAdapter里面。接着根据section获取pinned header view,可能需要新建也可能复用之前的view。
// get actual position of the first visible item firstVisibleItem -= getHeaderViewsCount(); // the section that first visible item belongs to int section = mAdapter.getPartitionForPosition(firstVisibleItem); // get the current header view based on the position(partition) mCurrentHeader = getSectionHeaderView(section, mCurrentHeader); // layout the current header view ensurePinnedHeaderLayout(mCurrentHeader); mHeaderOffset = 0.0f; |
获取pinned header view之后,需要为其绘制做准备。View的绘制都是通过canvas来实现,在绘制之前,需要通过measure和layout操作来确定view的大小和位置:
private void ensurePinnedHeaderLayout(View header) { if (header.isLayoutRequested()) { int widthSpec = MeasureSpec.makeMeasureSpec(getMeasuredWidth(), mWidthMode); int heightSpec; ViewGroup.LayoutParams layoutParams = header.getLayoutParams(); if (layoutParams != null && layoutParams.height > 0) { heightSpec = MeasureSpec.makeMeasureSpec(layoutParams.height, MeasureSpec.EXACTLY); } else { heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } header.measure(widthSpec, heightSpec); header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight()); } } |
接着从屏幕上可见的第一个item开始遍历,找到并计算出第一个header item的顶部Y坐标,和pinnedheader view的高度作比较。当该header item向上滑动到开始与pinned header view相交时,我们计算出交叉的高度作为pinnedheader view的Y轴偏移量,继续向上滑动,当headeritem的顶部Y坐标小于0,也就是开始要划出屏幕时我们设置它为invisible。这样做的目的是为了造成视觉错觉,好像header item被定在了ListView的顶部。如果我们不把它设为invisible,可以发现原来向上滑动的header item其实一直在向上滑动,并没有被定在ListView顶部。(如果pinned header view不透明的话,也发现不了)
for (int i = firstVisibleItem; i < firstVisibleItem + visibleItemCount; i++) { if (mAdapter.isPartitionHeader(i)) { View header = getChildAt(i - firstVisibleItem); float headerTop = header.getTop(); Log.e(TAG, "headerTop: " + headerTop); float pinnedHeaderHeight = mCurrentHeader.getMeasuredHeight(); header.setVisibility(VISIBLE); if (pinnedHeaderHeight >= headerTop && headerTop > 0) { mHeaderOffset = headerTop - header.getHeight(); } else if (headerTop <= 0) { header.setVisibility(INVISIBLE); } break; } } |
最后我们重写ListView的dispatchDraw()方法,实现pinned header view的绘制,该方法会在绘制子view之前调用。绘制实现如下:
// draw the pinned header view in the top of the list view @Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); if (mAdapter == null || !mShouldPin || mCurrentHeader == null) return; int saveCount = canvas.save(); canvas.translate(0, mHeaderOffset); mCurrentHeader.draw(canvas); canvas.restoreToCount(saveCount); } |
到此,PinnedHeaderListView的实现大体就分析完了。
上一张简单的效果图。
在学习PinneHeaderListView如何实现的过程中,也发现了源码中的几个可以改进的地方。简单修改后的代码地址随后放出。