原文在这里->Making your own 3D list – Part 1
标准的Android ListView支持许多特性,几乎涵盖了你能想到的所有场景。然而,这个Listview的外观太普通了,你可以继承他去做一些改变,但到最后你会发现这很困难。另一个不好的地方缺少华丽的物理特性。因此,如果你想让你的控件更好看,你需要去实现一个自己的view。
第一部分会创建一个基本的listview,确实有很多东西需要实现,但是我想先把它实现了,好在后面可以集中处理更有趣的东西上;第二部分我们会修改list的外观,做一些3D效果的绘制;在最后的第三部分,我们会改一下list的行为来增加一些物理特性,让我们的控件更酷一点。
Hello AdapterView
要实现一个列表控件来摆放其他的子view需要继承ViewGroup,最适合的就是AdapterView了(我们不继承AbsListView的原因是它不允许我们实现橡皮筋的效果)。所以先创建MyListView继承AdapterView<Adapter>,实现其中的两个抽象方法getAdapter()和setAdapter()。
public class MyListView extends AdapterView<Adapter> {
private Adapter mAdapter;
public MyListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public Adapter getAdapter() {
return mAdapter;
}
@Override
public View getSelectedView() {
return null;
}
@Override
public void setSelection(int position) {
}
@Override
public void setAdapter(Adapter adapter) {
mAdapter = adapter;
removeAllViewsInLayout();
requestLayout();
}
值得一提的是setAdapter()方法,当我们得到了一个新的adapter后移除了所有之前已经添加的view,然后请求重新布局。如果这时添加一些测试数据,并用在activity中,会发现屏幕上什么也没有,因为我们还没有覆写onLayout()方法。
Showing our first views
在onLayout方法中我们从adapter中取出子view并添加进去
@Override
protected void onLayout(boolean changed, int left, int top, int right,
int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (mAdapter == null) {
return;
}
if (getChildCount() == 0) {
int position = 0;
int bottomEdge = 0;
while (bottomEdge < getHeight() && position < mAdapter.getCount()) {
View view = mAdapter.getView(position, null, this);
addAndMeasureChild(view);
bottomEdge += view.getMeasuredHeight();
position++;
}
}
positionItems();
}
private void addAndMeasureChild(View child) {
LayoutParams lp = child.getLayoutParams();
if (lp == null) {
lp = new LayoutParams(LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT);
}
addViewInLayout(child, -1, lp);
int itemWidth = getWidth();
child.measure(MeasureSpec.EXACTLY | itemWidth, MeasureSpec.UNSPECIFIED);
}
private void positionItems() {
int top = 0;
for (int index = 0; index < getChildCount(); ++index) {
View childView = getChildAt(index);
int width = childView.getMeasuredWidth();
int height = childView.getMeasuredHeight();
int left = (getWidth() - width) / 2;
childView.layout(left, top, left + width, top + height);
top += height;
}
}
在while循环里添加view直到充满屏幕,我们从adapter中获取一个view,需要按顺序进行measure来得到准确的大小,然后添加到list里面,再摆放在正确的位置上。
在这里我们忽略了padding来简化实现。
Scrolling
如果现在运行程序就可以在屏幕上看到东西了,但是对手势操作没有反应,为此我们需要覆写onTouchEvent()方法。
实现滚动逻辑非常简单,当我们得到down事件时,存储一下手势的位置和列表现在的位置,我们将使用第一个列表项的上边沿来代表列表此时的位置。当得到移动事件时我们计算一下到之前down事件的距离,然后重新布局列表项。如果此时还没有添加子view,那就直接返回false了。
@Override
public boolean onTouchEvent(MotionEvent event) {
if (getChildCount() == 0) {
return false;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mTouchStartY = (int) event.getY();
mListTopStart = getChildAt(0).getTop();
break;
case MotionEvent.ACTION_MOVE:
int scrolledDistance = (int) event.getY() - mTouchStartY;
mListTop = mListTopStart + scrolledDistance;
requestLayout();
break;
default:
break;
}
return true;
}
列表的位置现在用mListTop确定,当它改变时我们就需要requesLayout来重新布局,之前的positionitems()从0开始布局,现在我们就可以把它改为mListTop了。
现在运行一下就会发现可以滚动了!但是有明显的问题,首先,滚动没有限制因此可以直接滚出屏幕,因此我们需要添加一些限制条件;其次,当向上滑动时没有新的子项添加进来,即使adapter中还有数据。我们把第一个问题先放一放,来修正第二个问题。
Handling all the items
没有新的列表项添加进来的原因在onLayout()中,那里限制了只有在第一次还什么都没有的时候添加子view。Listview的一个要求是展示10个项和展示1000个项的效果是一样的,因此,我们不能一次性添加完所有的列表项,需要更高效的做法,就是只添加屏幕上应该显示的那部分,而且将一部分项缓存起来让adapter能够重用。
这些都会在onLayout()中解决,新的方法是下面这个样子:
@Override
protected void onLayout(boolean changed, int left, int top, int right,
int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (mAdapter == null) {
return;
}
if (getChildCount() == 0) {
mLastItemPosition = -1;
fillListDown(0);
} else {
int offset = mListTop + mListTopOffset - getChildAt(0).getTop();
removeNonVisibleViews(offset);
fillList(offset);
}
positionItems();
invalidate();
}
fillListDown()将之前的while循环提了出来,而且还添加了一个相似的方法fillListUp()来从上添加列表项,这两个在fillList()中会被依次调用。removeNonVisibleViews()将滑出屏幕的view移除。为了能知道当前显示了哪些view,我们需要添加两个变量:mFirstItemPosition和mLastItemPosition,这两个分别表示当前显示的第一个view和最后一个view的位置,只要有添加或者移除的操作,这两个值都会改变。因为我们将列表的滑动关联在第一个可见的列表项的上边沿,所以需要在添加或移除view时更新这个值。
private void fillListUp(int offset) {
if (getChildCount() == 0) {
return;
}
int firstItemTop = getChildAt(0).getTop();
while (firstItemTop + offset > 0
&& mFirstItemPosition > 0) {
View view = mAdapter.getView(mFirstItemPosition - 1,
getCachedView(), this);
addAndMeasureChild(view, 0);
int viewHeight = view.getMeasuredHeight();
firstItemTop -= viewHeight;
mListTopOffset -= viewHeight;
--mFirstItemPosition;
}
}
private void fillListDown(int offset) {
int lastItemBottom = 0;
if (getChildCount() != 0) {
lastItemBottom = getChildAt(getChildCount() - 1).getBottom();
}
int parentHeight = getHeight();
while (lastItemBottom + offset < parentHeight
&& mLastItemPosition < mAdapter.getCount() - 1) {
View view = mAdapter.getView(mLastItemPosition + 1,
getCachedView(), this);
addAndMeasureChild(view, -1);
lastItemBottom += view.getMeasuredHeight();
++mLastItemPosition;
}
}
private View getCachedView() {
View view = null;
if (!mCachedViews.isEmpty()) {
view = mCachedViews.pop();
}
return view;
}
private void fillList(int offset) {
fillListDown(offset);
fillListUp(offset);
}
private void removeNonVisibleViews(int offset) {
if (getChildCount() == 0) {
return;
}
View firstView = getChildAt(0);
View lastView = getChildAt(getChildCount() - 1);
if (offset < 0 && firstView.getBottom() + offset < 0) {
removeViewInLayout(firstView);
mCachedViews.add(firstView);
mListTopOffset += firstView.getMeasuredHeight();
++mFirstItemPosition;
}
if (offset > 0 && lastView.getTop() + offset > getHeight()) {
removeViewInLayout(lastView);
mCachedViews.add(lastView);
--mLastItemPosition;
}
}
为了补偿positionItems()上下移动的距离,我们需要让removeNonVisibleView()和fillList()知道列表将移动多少距离,这就是offset变量的作用。同时,因为mListTop表示整个列表项的第一项的top,即使他已经不可见了,我们任然需要跟踪其当前第一个可见项的距离,这个就是mListTopOffset的作用。
private void positionItems() {
int top = mListTop + mListTopOffset;
for (int index = 0; index < getChildCount(); ++index) {
View childView = getChildAt(index);
int width = childView.getMeasuredWidth();
int height = childView.getMeasuredHeight();
int left = (getWidth() - width) / 2;
childView.layout(left, top, left + width, top + height);
top += height;
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (getChildCount() == 0) {
return false;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mTouchStartY = (int) event.getY();
mListTopStart = getChildAt(0).getTop() - mListTopOffset;
break;
case MotionEvent.ACTION_MOVE:
int scrolledDistance = (int) event.getY() - mTouchStartY;
mListTop = mListTopStart + scrolledDistance;
requestLayout();
break;
default:
break;
}
return true;
}
如果你实现了一个adapter,就可以通过使用convertView来提高性能,现在我们就需要实现它的另一面,也就是调用getView()时需要做的事。要实现重用需要一个view的缓存,标准的ListView支持了不同类型View的缓存,但是现在我们假设所有的view都是一种类型。实现缓存我们使用LinkedList,当移除view时(removeNonVisibleViews())加到缓存里,通过adapter获取view时(fillListDown和fillListUp)从缓存中取出来当作convertView。
Clicking and long-pressing
AdapterView实现了OnItemClickListener和OnItemLongClickListener的set方法,我们只要保证在合适的地方调用就行了。要支持点击我们需要做三件事情:1)捕捉点击事件,2)找到对应的项,3)调用listener。所以我们先从点击事件的捕获开始。
Android提供了一个GestureDetector可以用来干这个,但是这里不建议使用它,一个原因是它确实不靠谱,特别是对长按和滑动手势的识别,另一个原因是如果你将手势检测交给另一个类,将很难跟踪到你可能需要的当前手势的状态。
首先我们定义一下手势状态:
private static final int TOUCH_STATE_RESTING = 0;
private static final int TOUCH_STATE_CLICK = 1;
private static final int TOUCH_STATE_SCROLL = 2;
private int mCurrentTouchState = TOUCH_STATE_RESTING;
之前我们已经覆写了onTouchEvent(),现在就来添加一些东西来处理这些新状态。
@Override
public boolean onTouchEvent(MotionEvent event) {
if (getChildCount() == 0) {
return false;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
startTouch(event);
break;
case MotionEvent.ACTION_MOVE:
if (mCurrentTouchState == TOUCH_STATE_CLICK) {
startScrollIfNeeded(event);
}
if (mCurrentTouchState == TOUCH_STATE_SCROLL) {
int scrolledDistance = (int) event.getY() - mTouchStartY;
scrollList(scrolledDistance);
}
break;
case MotionEvent.ACTION_UP:
if (mCurrentTouchState == TOUCH_STATE_CLICK) {
clickChildAt((int) event.getX(), (int) event.getY());
}
endTouch();
break;
default:
endTouch();
break;
}
return true;
}
和之前的很相似,处理down事件的代码提到了一个单独的方法startTouch(),在里面我们将状态置为TOUCH_STATE_CLICK,现在还不知道用户接下来是点击还是滑动,我们先把它当作点击好了。
判断滑动的处理在startScrollIfNeeded()方法中,处理move事件是会调用该方法。它比较当前的手势坐标和之前的down事件坐标,如果手指移动距离超过一个阈值,就把状态置为TOUCH_STATE_SCROLL。通过ViewConfiguration.getScaledTouchSlop()可以得到系统预设的这个阈值。
滑动的处理代码和之前的一样,我们把它提到了scrollList()中。
private void startTouch(MotionEvent event) {
mTouchStartY = (int) event.getY();
mListTopStart = getChildAt(0).getTop() - mListTopOffset;
mCurrentTouchState = TOUCH_STATE_CLICK;
}
private void startScrollIfNeeded(MotionEvent event) {
int yDistance = (int) event.getY() - mTouchStartY;
int threshold = ViewConfiguration.get(getContext())
.getScaledTouchSlop();
if (Math.abs(yDistance) > threshold) {
mCurrentTouchState = TOUCH_STATE_SCROLL;
}
}
private void scrollList(int scrolledDistance) {
mListTop = mListTopStart + scrolledDistance;
requestLayout();
}
private void endTouch() {
mCurrentTouchState = TOUCH_STATE_RESTING;
}
要完成点击事件的处理需要捕获ACTION_UP事件,其他情况我们就同一在endTouch()中将状态置为TOUCH_STATE_RESTING。当然只有在点击的状态下才去调用listener,而不是在scrolling状态下。
private void clickChildAt(int x, int y) {
int index = getContainingChildIndex(x, y);
if (index != INVALID_INDEX) {
int position = mFirstItemPosition + index;
long id = mAdapter.getItemId(position);
performItemClick(getChildAt(index), position, id);
}
}
private int getContainingChildIndex(int x, int y) {
Rect rect = new Rect();
for (int i = 0; i < getChildCount(); ++i) {
View view = getChildAt(i);
view.getHitRect(rect);
if (rect.contains(x, y)) {
return i;
}
}
return INVALID_INDEX;
}
clickChildAt()是为了找到点击区域对应的列表项,在getContainingChildIndex()中使用一个循环来对每一个屏幕上的view进行检查,看点击的坐标是否落在了某一个view上。
有了处理点击事件的逻辑,添加长按事件的处理就很简单了,一种简便的方式是做一个Runnable来调用长按的listener,当down事件发生时就将这个Runnable延时执行,当up事件发生或者状态切换到了滚动,我们知道长按事件不会发生了,所以使用removeCallback()将Runnable移除。具体多长时间是长按可以由你指定,建议使用系统的延时ViewConfiguration.getLongPressTimeout()。
private void startLongClickCheck() {
if (mLongClickRunnable == null) {
mLongClickRunnable = new Runnable() {
@Override
public void run() {
if (mCurrentTouchState == TOUCH_STATE_CLICK) {
int index = getContainingChildIndex(mTouchStartX,
mTouchStartY);
mCurrentTouchState = TOUCH_STATE_RESTING;
onLongClick(index);
}
}
};
}
postDelayed(mLongClickRunnable, ViewConfiguration.getLongPressTimeout());
}
private void onLongClick(int index) {
View childView = getChildAt(index);
int position = mFirstItemPosition + index;
long id = mAdapter.getItemId(position);
if (getOnItemLongClickListener() != null) {
getOnItemLongClickListener().onItemLongClick(this, childView,
position, id);
}
}
为了能够在子view响应触摸事件的情况下依然能够滚动,你需要截获触摸事件,这个可以通过覆写onInterceptTouchEvent()来控制所有事件是否向子view进行传递。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
startTouch(ev);
return false;
case MotionEvent.ACTION_MOVE:
return startScrollIfNeeded(ev);
default:
endTouch();
return false;
}
}
onInterceptTouchEvent()的实现和onTouchEvent()很像。
To be continued...
到目前为止我们完成了一个非常简单的ListView,可以高效地处理添加子view,滚动、短按和长按。下一部分我们将让list有个3D的效果,这之后将处理它的动态效果像回弹和滑动。
运行效果如下