源码解析
豆瓣电影推荐卡片层叠效果,自定义ViewGroup方式实现,view复合动画,事件处理,view绘制,自带view缓存复用机制。
效果示例
交互效果描述
开始只有一张卡片,随着第二张卡片慢慢往上面叠加,第一张卡片会做位移动画、缩放动画和alpha动画,直到第二张卡片盖住第一张卡片。同样地慢慢滑动第三张卡片,第一张以及第二张同时做位移动画、缩放动画和alpha动画。直到第三张卡片盖住第二张卡片。滑动第四张卡片时,第一张、第二张以及第三张做位移动画、缩放动画和alpha动画。
特别地,从滑动第五张卡片开始,第一张卡片会消失,第二张到第四张做位移动画、缩放动画和alpha动画。以此类推。
卡片放置的动画过程中,不允许打断。不能两个动画同时进行。但是可以通过多次点击滑动的方式干预某个动画。按照卡片是否做动画可以把屏幕中卡片分成两组,一组是层叠的卡片他们会做动画,一组是等待滑动到层叠区域的卡片,他们只左右移动没有动画。
该控件支持左右滑动。支持抛动。有阻尼回弹效果。从左边看,卡片位于起始位置时,继续向右边滑动,无过度拉伸效果。从右边看,卡片位于起终止位置时,继续向左边滑动,无过度拉伸效果。滑动过程有监听,进而可以协调外部元素联动。
实现原理
卡片滑动事件处理
1. 由于是ViewGroup方式实现的,所以使用了自己的一套计算坐标方法,其实就是取代了系统的scrollTo和scrollBy方法,使用scrollToInner和scrollByInner,相应地,新增mScrollX来追踪总体的偏移量。
private void scrollByInner(int x, int y) {
scrollToInner(mScrollX + x, mScrollY + y);
}
private void scrollToInner(int x, int y) {
if (mScrollX != x || mScrollY != y) {
if (x > 0) {
x = 0;
}
float minScrollX = (mItemCount - 1) * (mItemMarginLeft + mCardViewWidth + mItemMarginRight + mItemDividerWidth);
if (x < -1.0f * minScrollX) {
x = (int) (-1.0f * minScrollX);
}
mScrollX = x;
mScrollY = y;
invalidateAnimation();
}
}
2. 复写dispatchTouchEvent方法,监听各类事件,在这里改变坐标并触发子view滑动。并且在UP事件中根据滑动位置mScrollX和临界值A的关系做自动滚动的动画。自动滚动的动画除了收到临界值的影响还收到滑动速率的影响,比如向右抛动导致速率超过抛动临界值B,这时候即便滑动位置mScrollX没有到达临界值A也会触发向有自动滑动的动画。这样做是为了和用户的操作预期保持一致。
switch (ev.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
mLastMotionX = mInitialMotionX = ev.getX();
mLastMotionY = mInitialMotionY = ev.getY();
mActivePointerId = ev.getPointerId(0);
break;
case MotionEvent.ACTION_MOVE:
if (!mIsBeingDragged) {
final int pointerIndex = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(pointerIndex);
final float xDiff = Math.abs(x - mLastMotionX);
final float y = ev.getY(pointerIndex);
final float yDiff = Math.abs(y - mLastMotionY);
if (xDiff > mTouchSlop && xDiff > yDiff) {
mIsBeingDragged = true;
mLastMotionX = mLastMotionX - mInitialMotionX > 0 ? mInitialMotionX + mTouchSlop :
mInitialMotionX - mTouchSlop;
// Disallow Parent Intercept, just in case
ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
}
if (mIsBeingDragged) {
// Scroll to follow the motion event
final float x = ev.getX();
needsInvalidate = performDrag(x);
}
break;
...
3. 记录DOWN事件和MOVE事件的滑动偏移量,从而触发子view的偏移。子view的偏移是在performDrag方法中实现的。
if (mIsBeingDragged) {
// Scroll to follow the motion event
final float x = ev.getX();
performDrag(x);
}
performDrag方法内部会改变mScrollX的值并且触发重新绘制,进而改变子view的在屏幕上位置。
private void scrollToInner(int x, int y) {
if (mScrollX != x || mScrollY != y) {
if (x > 0) {
x = 0;
}
float minScrollX = (mItemCount - 1) * (mItemMarginLeft + mCardViewWidth + mItemMarginRight + mItemDividerWidth);
if (x < -1.0f * minScrollX) {
x = (int) (-1.0f * minScrollX);
}
mScrollX = x;
mScrollY = y;
invalidateAnimation();
}
}
触发重新绘制是通过invalidateAnimation实现的,该方法内部会调用onPageScrolled, onPageScrolled代码如下
/**
* 滑动卡片时,下层的卡片有一个缩放动画和位移和aplha动画,位移的落点是左侧
*/
protected void onPageScrolled() {
// Offset any decor views if needed - keep them on-screen at all times.
final int scrollX = mScrollX;
final int width = getWidth();
int childCount = getChildCount();
int start = 0;
int count = 0;
final int firstPosition = mFirstPosition;
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
offsetChildLeftAndRight(scrollX, child, i + firstPosition);
float scale = scaleChild(child, scrollX, i + firstPosition);
float alpha = alphaChild(child, scrollX, i + firstPosition);
float tx = translateChild(child, scrollX, i + firstPosition);
if (mPageTransformer != null) {
final float transformPos = (float) (child.getLeft() - scrollX) / ((mItemMarginLeft + mCardViewWidth + mItemDividerWidth));
mPageTransformer.transformPage(child, transformPos);
}
}
switch (DIRECTION) {
case SCROLL_DIRECTION_LEFT:
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
if (child.getAlpha() >= ALPHA_RATIO_L0) {
break;
} else {
count++;
int position = firstPosition + i;
// The view will be rebound to new data, clear any
// system-managed transient state.
mRecycler.addScrapView(child, position);
Log.d(CARD_TAG, "mRecycler addScrapView left -> position=" + position);
}
}
break;
case SCROLL_DIRECTION_RIGHT:
int childMaxRight = getWidth() - getPaddingRight();
for (int i = childCount - 1; i >= 0; i--) {
final View child = getChildAt(i);
if (child.getLeft() <= childMaxRight) {
break;
} else {
start = i;
count++;
int position = firstPosition + i;
mRecycler.addScrapView(child, position);
Log.d(CARD_TAG, "mRecycler addScrapView right -> position=" + position);
}
}
break;
case SCROLL_DIRECTION_NONE:
break;
}
if (count > 0) {
Log.d(CARD_TAG, "mRecycler -> detachViewsFromParent start=" + start + ", count=" + count);
detachViewsFromParent(start, count);
mRecycler.removeSkippedScrap();
}
if (DIRECTION == SCROLL_DIRECTION_LEFT) {
mFirstPosition += count;
}
final boolean down = DIRECTION == SCROLL_DIRECTION_LEFT;
final boolean up = DIRECTION == SCROLL_DIRECTION_RIGHT;
final boolean loadLeft = up && getChildAt(0).getAlpha() >= ALPHA_RATIO_L0;
final int absIncrementalDeltaY = (int) Math.abs(incrementalDeltaY);
final int end = getWidth() - getPaddingRight();
int lastBottom = getChildAt(getChildCount() - 1).getRight();
final int spaceBelow = lastBottom - end;
if (loadLeft || spaceBelow < absIncrementalDeltaY) {
fillGap(down);
}
mRecycler.fullyDetachScrapViews();
}
4. 实现子view的偏移是通过修改left坐标实现的,在此过程中也会进行子view的位移动画、缩放动画和alpha动画。需要注意的一个细节是,子view有很多,每一个做动画的状态都不同,有两种思路来实现这些动画状态。一种思路是逐个记录每个字view的状态并且实时更新;另一种思路是根据mScrollX来计算,由于mScrollX是全局偏移量,因此可以通过mScrollX算出来某个子view的left以及动画因子。这里采用的是第二种思路。
int childLeft = paddingLeft + scrollX;
final int childOffset = childLeft - child.getLeft();
if (childOffset != 0) {
child.offsetLeftAndRight(childOffset);
}
相应地,给子view做动画也是根据mScrollX计算的。子view的动画有位移动画、缩放动画和alpha动画。这里只示例位移动画,其他动画方式类似。
private float translateChild(View child, int scrollX, int childIndex) {
int r = scrollX +
childIndex * (mItemMarginLeft + mCardViewWidth + mItemDividerWidth + mItemMarginRight);
int R = mItemMarginLeft + mCardViewWidth + mItemMarginRight + mItemDividerWidth;
float tx = 0.f;
if (r > R) {
// 不偏移
} else if (r > 0) {
tx = (r - R) * 1.0f / R * mTranslateX;
} else {
tx = r * 1.0f / R * mTranslateX - mTranslateX;
}
child.setTranslationX(tx);
return tx;
}
5. view的滑动偏移效果,采用的是ViewPager里的偏移算法。具体参见ViewPager源码,这里引用部分ViewPager源码。
if (!mIsBeingDragged) {
final int pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex == -1) {
// A child has consumed some touch events and put us into an inconsistent
// state.
needsInvalidate = resetTouch();
break;
}
final float x = ev.getX(pointerIndex);
final float xDiff = Math.abs(x - mLastMotionX);
final float y = ev.getY(pointerIndex);
final float yDiff = Math.abs(y - mLastMotionY);
if (DEBUG) {
Log.v(TAG, "Moved x to " + x + "," + y + " diff=" + xDiff + "," + yDiff);
}
if (xDiff > mTouchSlop && xDiff > yDiff) {
if (DEBUG) Log.v(TAG, "Starting drag!");
mIsBeingDragged = true;
requestParentDisallowInterceptTouchEvent(true);
mLastMotionX = x - mInitialMotionX > 0 ? mInitialMotionX + mTouchSlop :
mInitialMotionX - mTouchSlop;
mLastMotionY = y;
setScrollState(SCROLL_STATE_DRAGGING);
setScrollingCacheEnabled(true);
// Disallow Parent Intercept, just in case
ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
}
// Not else! Note that mIsBeingDragged can be set above.
if (mIsBeingDragged) {
// Scroll to follow the motion event
final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(activePointerIndex);
needsInvalidate |= performDrag(x);
}
6. scrollToInner边界值处理
7. 在pageScrolled方法代码的下半部分还有view的回收和复用机制。回收屏幕外的子view并且缓存起来,当加载新的子view时,优先从缓存数组中取,如果取不到采取加载新的view。具体复用机制见下一节。
view复用机制
1. 为了更方便使用本控件,本控件用于绑定数据的Adapter和ListView的Adapter用法一致。因而view复用机制也和ListView复用机制类似。
public int getCount() {
return mCardBeans.size();
}
@Override
public abstract Object getItem(int position);
@Override
public long getItemId(int position) {
return position;
}
@Override
public abstract CardView getView(int position, View convertView, ViewGroup parent);
2. 基本原理是,通过检测滑动方向向左还是向右,来判断子view的坐标和父控件绘制区域边界坐标的关系。
-
如果向左滑动,则从index=0的子view开始遍历,回收父控件绘制区域边界之外的子view。直到遍历到当前屏幕可见的子view处停止,因为屏幕可见的子view正在使用中,不能回收。回收的方式只是detach view,并且把该view缓存起来备用。其中缓存管类同listview的RecycleBin。
-
如果向右滑动,则从childCount-1的子view开始向前遍历,回收父控件绘制区域边界之外的子view,直到遍历到当前屏幕可见的子view处停止,因为屏幕可见的子view正在使用中不能回收。
case SCROLL_DIRECTION_RIGHT:
int childMaxRight = getWidth() - getPaddingRight();
for (int i = childCount - 1; i >= 0; i--) {
final View child = getChildAt(i);
if (child.getLeft() <= childMaxRight) {
break;
} else {
start = i;
count++;
int position = firstPosition + i;
mRecycler.addScrapView(child, position);
Log.d(CARD_TAG, "mRecycler addScrapView right -> position=" + position);
}
}
break;
3. 滑动会触发回收,同时自然也会触发复用。复用的触发机制也和滑动方向有关系。
-
如果向左滑动,当最右边的子view的right坐标减去MOVE deltaX 后如果小于父控件的右边界坐标值则加载后续子view,直到当最右边的子view的right坐标减去MOVE delta 后大于等于父控件的右边界。
final int end = getWidth() - getPaddingRight(); int lastBottom = getChildAt(getChildCount() - 1).getRight(); final int spaceBelow = lastBottom - end; if (spaceBelow < absIncrementalDeltaX) { fillGap(down); }
-
如果向右滑动,当最左边的子view的left坐标加上MOVE delta 后如果大于父控件的左边界坐标值则加载前面的子view,直到当最前面的子view的left坐标加上MOVE delta后小于等于父控件的左边界。
复用的逻辑是在fillGap中实现的,fillGap内部会根据滑动方向调用fillDown或者fillUp方法。(其实改成fillLeft或者fillRight更贴切,这里沿用了listview中的命名方法)。fillDown方法代码如下,
private void fillDown(int pos, int nextLeft) {
int end = (mBottom - mTop);
while (nextLeft < end && pos < mItemCount) {
makeAndAddView(pos, nextLeft, true, nextLeft, false);
nextLeft = mScrollX + getPaddingLeft() + mChildrenContentMarginLeft + mItemMarginLeft + (pos + 1) * (mItemMarginLeft + mCardViewWidth + mItemDividerWidth + mItemMarginRight);
pos++;
}
}
在fillDown内部会调用makeAndAddView创建子view,当然创建之前会检测是否可以复用。
makeAndAddView的代码如下,
private View makeAndAddView(int position, int left, boolean flow, int childrenLeft,
boolean selected) {
if (!mDataChanged) {
// Try to use an existing view for this position.
final View activeView = mRecycler.getActiveView(position);
if (activeView != null) {
// Found it. We're reusing an existing child, so it just needs
// to be positioned like a scrap view.
setupChild(activeView, position, left, flow, childrenLeft, selected, true);
return activeView;
}
}
// Make a new view for this position, or convert an unused view if
// possible.
final View child = obtainView(position, mIsScrap);
// This needs to be positioned and measured.
setupChild(child, position, left, flow, childrenLeft, selected, mIsScrap[0]);
return child;
}
会优先从recycleBin中取可用的缓存,如果又则直接返回使用,没有才会调用obtainView去创建。obtainView方法内部实现是
{
outMetadata[0] = false;
// Check whether we have a transient state view. Attempt to re-bind the
// data and discard the view if we fail.
final View transientView = mRecycler.getTransientStateView(position);
if (transientView != null) {
final LayoutParams params = (LayoutParams) transientView.getLayoutParams();
Log.d(CARD_TAG, "re-bind transientView " + position);
// If the view type hasn't changed, attempt to re-bind the data.
if (params.viewType == mAdapter.getItemViewType(position)) {
final View updatedView = mAdapter.getView(position, transientView, this);
// If we failed to re-bind the data, scrap the obtained view.
if (updatedView != transientView) {
setItemViewLayoutParams(updatedView, position);
mRecycler.addScrapView(updatedView, position);
}
}
outMetadata[0] = true;
// Finish the temporary detach started in addScrapView().
transientView.dispatchFinishTemporaryDetach();
return transientView;
}
final View scrapView = mRecycler.getScrapView(position);
final View child = mAdapter.getView(position, scrapView, this);
if (scrapView != null) {
Log.d(CARD_TAG, "re-bind scrapView " + position);
if (child != scrapView) {
// Failed to re-bind the data, return scrap to the heap.
mRecycler.addScrapView(scrapView, position);
} else if (child.isTemporarilyDetached()) {
outMetadata[0] = true;
// Finish the temporary detach started in addScrapView().
child.dispatchFinishTemporaryDetach();
}
}
setItemViewLayoutParams(child, position);
return child;
}
这个方法内部如果拿到了可用的缓存,就会调用adapter中的getView方法,并且把参数scrapView传递过去,这个参数就是adapter中的contentView。如果没有可用的缓存则参数scrapView为null,也就意味着adapter中的contentView为null。这样就和listView的adapter的经典使用场景串联起来了。
4. 获取子view的方法同listview adapter的使用方式
@Override
public CardView getView(int position, View convertView, ViewGroup parent) {
View view;
if (convertView == null) {
view = new MovieCardView(mContext);
} else {
Log.d(CARD_TAG, "getView reuse " + position);
view = convertView;
}
}
5. 获取到子view之后,就是设置子view的属性。首先设置LayoutParams,其次如果是复用的view则重新attach parent,如果是新创建的子view则addViewInLayout。再次是检测子view是否需要measure。最后是调用子view的layout方法修改子 view的坐标值。代码如下
private void setupChild(View child, int position, int left, boolean flowDown, int childrenLeft,
boolean selected, boolean isAttachedToWindow) {
final boolean needToMeasure = !isAttachedToWindow
|| child.isLayoutRequested();
// Respect layout params that are already in the view. Otherwise make
// some up...
LayoutParams p = (LayoutParams) child.getLayoutParams();
if (p == null) {
Log.d(CARD_TAG, "setupChild LayoutParams is null, generateDefaultLayoutParams");
p = (LayoutParams) generateDefaultLayoutParams();
}
p.viewType = mAdapter.getItemViewType(position);
p.isEnabled = mAdapter.isEnabled(position);
if ((isAttachedToWindow && !p.forceAdd)) {
Log.d(CARD_TAG, "setupChild attachViewToParent");
attachViewToParent(child, flowDown ? -1 : 0, p);
} else {
p.forceAdd = false;
Log.d(CARD_TAG, "setupChild addViewInLayout");
addViewInLayout(child, flowDown ? -1 : 0, p, true);
}
if (needToMeasure) {
final int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec,
mListPadding.left + mListPadding.right, p.width);
final int lpHeight = p.height;
final int childHeightSpec;
if (lpHeight > 0) {
childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY);
} else {
childHeightSpec = MeasureSpec.makeMeasureSpec(getMeasuredHeight(),
MeasureSpec.UNSPECIFIED);
}
child.measure(childWidthSpec, childHeightSpec);
} else {
cleanupLayoutState(child);
}
final int w = child.getMeasuredWidth();
final int h = child.getMeasuredHeight();
final int childTop = getPaddingTop() + mItemMarginTop;
final int childLeft = flowDown ? left : left - w;
if (needToMeasure) {
final int childRight = childLeft + w;
final int childBottom = childTop + h;
child.layout(childLeft, childTop, childRight, childBottom);
} else {
child.offsetLeftAndRight(childLeft - child.getLeft());
child.offsetTopAndBottom(childTop - child.getTop());
}
}
到此view的复用机制就结束了。
总结一下,本控件中有两个核心技术点,一个是通过监听滑动事件实现子 view的移动以及动画;另一个是子view的复用机制,复用机制借鉴了listview的复用机制原理。感兴趣的同学可以在源代码基础上继续修改定制,也可以参考源码学习自定义view和view复用机制的原理。
源代码地址 DoubanMovieCard