实现一个定制的3D ListView——第一部分

原文在这里->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的效果,这之后将处理它的动态效果像回弹和滑动。


运行效果如下

代码在这里->http://download.csdn.net/detail/xu_fu/7019733

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值