cardsui-for-android的下载地址https://github.com/Androguide/cardsui-for-android
以下是我截取的2个图片,可以自定义成Card形式的View,布局可以自己设定。点击露出来的部分可以使点击的Card滑落到下面,也可以左右滑动删除Card。效果非常好
这篇文章主要写下通过源码分析一下几个地方是怎么实现的。
Card的View和布局
相互叠层的card
点击翻转下滑
左右移动删除条目
可以根据需求自行决定观看,哈哈
从git下载导入项目中后,一共有3个文件,一个是Library文件,另外两个是作者写的Demo,第一个很简单,第二个demo是可以用调色板动态创建Card,所以需要另外下载ColorPicker和actionbarsherlock开源项目。
这篇文章只分析源码,所以就不看Demo了,只需要看Library文件就行,下面正式开始:
CardsUILib文件截图
如图一共只有3个包,10多个类。
这里我们只关心这几个类Card, CardStack, StackAdapter, SwipDismissTouchListener, CardUI,下面我们来一个一个分析
定义Card的view和布局详解:
Card
该类是一个抽象类并且继承至AbstractCard抽象类,会重写该抽象类的getView方法,请看我加的中文注解
@Override public View getView(Context context) { //getCardLayout(),这个方法返回一个布局,该布局只有一个FrameLayout,可以理解这句就是创建一个干净的空布局 View view = LayoutInflater.from(context).inflate(getCardLayout(), null); mCardLayout = view; try { //这个是通过getCardContent()方法获取View加到之前的空布局上 //getCardContent()方法是一个抽象类,具体由子类实现,其实就是为了把子类的View加到父View的布局上 ((FrameLayout) view.findViewById(R.id.cardContent)) .addView(getCardContent(context)); } catch (NullPointerException e) { e.printStackTrace(); } //为这个布局设置宽高,和Margin,最后返回这个布局,这个布局就是Card的根View了,具体的‘长相’就看子类的实现啦 LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); int bottom = Utils.convertDpToPixelInt(context, 12); lp.setMargins(0, 0, 0, bottom); view.setLayoutParams(lp); return view; }Card类就是未来我们自定义Card类的父类,里面定义了一些规范当然他也从AbstractCard中继承了一些规范。
Card类里还有一些其他的方法,比如设置了setOnClickListener(), getOnLongClickListener() 还有一个重要的接口OnCardSwiped 里面有个onCardSwiped方法,这些都是让自定义子类去看需求来实现的,过多的细节就不啰嗦
CardStack
该类的作用看名字就可以猜到,他是一个Card堆,或者说是Card的一个集合,专门存放Card。
讲到这里,需要跳一下,暂时把这个类放下先去看另一个类CardUI。一会回来再接着看他
CardUI
该类继承自FrameLayout。哈哈,说到这里大家是不是已经知道怎么回事了。
没错,CardUI, CardStack, Card这3个类是一一包含的关系。CardStack装入各种Card,最后再把他放到最终父布局CardUI中。
我截个图大家理解更清晰
所以CardUI中一定有addCard方法 和addStack方法
public void addCard(Card card, boolean refresh) { CardStack stack = new CardStack(); stack.add(card); mStacks.add(stack); if (refresh) refresh(); } public void addStack(CardStack stack, boolean refresh) { mStacks.add(stack); if (refresh) refresh(); }每次add之后都会调用一下refresh()方法,原因当然就是要重新来调整布局的大小啦
看refresh方法前先看一下这个类初始化的一些代码
//该方法在构造方法中调用,目的就是初始化该自定义布局用到的 private void initData(Context context) { mContext = context; LayoutInflater inflater = LayoutInflater.from(context); //所有CardStack的集合 mStacks = new ArrayList<AbstractCard>(); //inflate a different layout, depending on the number of columns //自定义一个listview作为容器 if (mColumnNumber == 1) { inflater.inflate(R.layout.cards_view, this); // init observable scrollview mListView = (QuickReturnListView) findViewById(R.id.listView); } else { //initialize the mulitcolumn view //使用TableLayout作为容器,其原理和使用listview基本相同,不过效果没有listview好,所以基本不用它 inflater.inflate(R.layout.cards_view_multicolumn, this); mTableLayout = (TableLayout) findViewById(R.id.tableLayout); } // mListView.setCallbacks(this); mHeader = inflater.inflate(R.layout.header, null); mQuickReturnView = (ViewGroup) findViewById(R.id.sticky); mPlaceholderView = mHeader.findViewById(R.id.placeholder); }
作者的源码默认就是用自定义Listview(QuickReturnListView)作为父View放在自定义FrameLayout(CardUI)布局中,我用过一次TableLayout,结果效果非常不好,我觉得原因可能是因为listview用adapter赋值的原因。
另外这里我们其实不需要定义一个自定义的Listview,用系统带的就行,作者这里用了一个自定义的listview-QuickReturnListView,这个自定义Listview目的是为了添加一个随着拖拽一起移动的header用的,这部分代码可以去看CardUI的setHeader()方法。我觉得这个header没必要这么做,而且作者在给的demo中也没有这方面的例子,所以这里就不管它了。直接看refresh方法
public void refresh() { if (mAdapter == null) { //StackAdapter继承自BaseAdapter,用于过listview都知道干什么的,这里就把他当listview用法一样想就对了 mAdapter = new StackAdapter(mContext, mStacks, mSwipeable); //这个判断是用mListview做父view还是mTableLayout做父view if (mListView != null) { //如果是listview做父view就可以这样想象:这个自定义的FrameLayout中有一个Listview,这个Listview中的每个Item就是一个CardStack mListView.setAdapter(mAdapter); } else if (mTableLayout != null) { //如果是TableLayout做父View就可以想象成:这个自定义的FrameLayout中有一个TableRow,每一个Row里面放一个CardStack,原理和Listview相同,因为基本不用TableRow,所以关于TableRow的代码就省略了 ..... }
上面代码的目的就是实例化一个StackAdapter适配器为Listvew赋值,所以直接来到StackAdapter
StackAdapter
只看getView方法就够了
@Override public View getView(int position, View convertView, ViewGroup parent) { //获取当前的CardStack final CardStack stack = getItem(position); stack.setAdapter(this); stack.setPosition(position); // the CardStack can decide whether to use convertView or not //调用stack的getView,就是为了把存放在Stack中的Card的view都取出来,放在convertView中返回给Listview作为item的View convertView = stack.getView(mContext, convertView, mSwipeable); return convertView; }
看到这个stack.getView了吧,转了一大圈终于转回来了。下面主要来讲这个stack中重写的getView方法
Card相互叠层样式详解
Card叠层的效果其实就是通过多个view的覆盖来实现的,具体看下面代码
CardStack--getView
这个getView方法就是把所有的Card绘制出来,并且设置一些card的监听
public View getView(Context context, View convertView, boolean swipable) { mContext = context; ..... //这个是CardStack的根view了(也就是每个listview的item的view),布局文件可以自己定义,想要什么样的自己改就好了 final View view = LayoutInflater.from(context).inflate( R.layout.item_stack, null); ..... //这里开始就通过循环将CardStack中每个Card的view加入父view容器中,每个Card的具体处理的代码也都是在这里,所以这是本文最重点的地方了 Card card; View cardView; //循环遍历每一个Card for (int i = 0; i < cardsArraySize; i++) { card = cards.get(i); //初始化布局大小 RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams( RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.WRAP_CONTENT); int topPx = 0; // handle the view if (i == 0) { //第一个,也就是最下面的一个 cardView = card.getViewFirst(context); } else if (i == lastCardPosition) { //最后一个,也就是最上面的一个 cardView = card.getViewLast(context); } else { //中间的 cardView = card.getView(context); } // handle the listener //最后一个Card,也就是最上面的一个,直接和我们交互的一个Card //设置它的点击监听,这里的实现写在具体的子类里面,因为不同的Card的点击操作各不相同 if (i == lastCardPosition) { cardView.setOnClickListener(card.getClickListener()); cardView.setOnLongClickListener(card.getOnLongClickListener()); } //非最后一个Card,也就是不是最上面的card,是被盖住的。 //这里的监听主要是为了点击他漏出来的部分可以让他翻转覆盖到最上面,所以这个监听的具体实现可以写在本类中,因为这个是共通的 else { cardView.setOnClickListener(getClickListener(this, container, i)); cardView.setOnLongClickListener(getOnLongClickListener(this, container, i)); } //*这里打个星号,这段代码就是控制Card视图叠层效果的,原理就是从第二个card开始,向下移动特定的位置,这个位置取决与你的CardStack的布局文件,这里是45f和最后减去12f的调整。 if (i > 0) { float dp = (_45F * i) - _12F; topPx = Utils.convertDpToPixelInt(context, dp); } //计算出第二个和第一个的偏移量以后,就设置它的margin,就会实现出一个叠一个并且露出头部的效果了 lp.setMargins(0, topPx, 0, 0); cardView.setLayoutParams(lp); //判断是否可以左右滑动 if (swipable) { //为cardview设置触摸监听器,监听器的实现是一个自定义的SwipeDismissTouchListener, //这里有个自定义的Callback回调接口--OnDismissCallback,里面有一个onDismiss回调方法 //先不急看他,先进入到SwipeDismissTouchListener监听器中去看看 cardView.setOnTouchListener(new SwipeDismissTouchListener( cardView, card, new OnDismissCallback() { @Override public void onDismiss(View view, Object token) { //含简单,就是删除这个Card,并通知adapter Card c = (Card) token; // call onCardSwiped() listener c.OnSwipeCard(); cards.remove(c); mAdapter.setItems(mStack, getPosition()); // refresh(); mAdapter.notifyDataSetChanged(); } })); } //将cardView放在View容器中 container.addView(cardView); } return view; }
左右滑动删除card详解:
SwipeDismissListener
这个类继承View.OnTouchListener接口,所以需要重写onTouch方法,去监听用户的触摸操作
在switch中判断触摸操作,这里有3个DOWN,UP,MOVE分别是按下,抬起,滑动
执行顺序是按下,滑动,抬起 或者是按下,抬起
@Override public boolean onTouch(View view, MotionEvent motionEvent) { // offset because the view is translated during swipe motionEvent.offsetLocation(mTranslationX, 0); if (mViewWidth < 2) { //取得view的宽度 mViewWidth = mView.getWidth(); } switch (motionEvent.getActionMasked()) { //按下操作 case MotionEvent.ACTION_DOWN: { //记录按下点的X坐标 mDownX = motionEvent.getRawX(); //一个速率记录器,就是记录操作速度用的,具体的去google吧 mVelocityTracker = VelocityTracker.obtain(); mVelocityTracker.addMovement(motionEvent); //如果返回false,表明处理没有完成,可以交给onClick,和onLongClick接着处理 //如果返回true,则表明已经对该事件做了处理,不会继续传递下去了 //这里我们还需要onClick,onLongClick处理,所以返回false return false; } //抬起操作,建议先看下面的滑动操作后再来看抬起操作,因为这是一个完整的操作顺序 case MotionEvent.ACTION_UP: { if (mVelocityTracker == null) { break; } //deltaX表示当前点减去按下时的点的差,就是X轴上移动的距离 float deltaX = motionEvent.getRawX() - mDownX; //下面4句就是得到滑动时X轴的速度,和Y轴的速度 mVelocityTracker.addMovement(motionEvent); mVelocityTracker.computeCurrentVelocity(1000); float velocityX = Math.abs(mVelocityTracker.getXVelocity()); float velocityY = Math.abs(mVelocityTracker.getYVelocity()); //dismiss这个变量记录的此次滑动是否成功,成功则删除,不成功复原 boolean dismiss = false; boolean dismissRight = false; //成功条件1,滑动的距离大于当前view的一半宽 if (Math.abs(deltaX) > mViewWidth / 2) { dismiss = true; //记录,判断滑动时向左还是向右 dismissRight = deltaX > 0; } //成功条件2,X轴滑动速度大于设定的一个最小速度,小于设定的最大速度,并且大于Y轴的速度 else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity && velocityY < velocityX) { dismiss = true; //记录,判断滑动时向左还是向右 dismissRight = mVelocityTracker.getXVelocity() > 0; } //上面2个条件满足任何一个都可以执行滑动删除 if (dismiss) { //执行动画,先判断是左滑还是右滑,之后朝着这个方向移动整个view长度,并且透明度减到0,在动画结束时调用performDismiss()方法 animate(mView) .translationX(dismissRight ? mViewWidth : -mViewWidth) .alpha(0).setDuration(mAnimationTime) .setListener(new AnimatorListener() { //动画结束时调用 @Override public void onAnimationEnd(Animator arg0) { performDismiss(); } }); } else { .......... } //手指滑动操作 case MotionEvent.ACTION_MOVE: { if (mVelocityTracker == null) { break; } mVelocityTracker.addMovement(motionEvent); //deltaX表示当前点减去按下时的点的差,就是X轴上移动的距离 float deltaX = motionEvent.getRawX() - mDownX; //这个判断可以理解为手指滑动幅度,超过规定距离后,可以执行滑动删除操作 if (Math.abs(deltaX) > mSlop) { //是否可以滑动标记,标记为ture mSwiping = true; //标记为true以后,意思可以理解为onTouchEvent事件不会再继续传递 mView.getParent().requestDisallowInterceptTouchEvent(true); // Cancel listview's touch //这里已经完成任务,所以new一个cancelEvent取消该次触摸操作 MotionEvent cancelEvent = MotionEvent.obtain(motionEvent); cancelEvent .setAction(MotionEvent.ACTION_CANCEL | (motionEvent.getActionIndex() << MotionEvent.ACTION_POINTER_INDEX_SHIFT)); mView.onTouchEvent(cancelEvent); cancelEvent.recycle(); } //判断能否执行滑动删除 if (mSwiping) { //这个是刚才得到的滑动操作在X轴上的位移量 mTranslationX = deltaX; //这里使用animator动画的setTranslationX方法,将当前的view在X轴上移动deltaX个偏移量 //这里要说一下animator动画,他是3.0以后加入的新动画,他和animation的区别我理解就是animation只是View的绘制效果的改变,而真正View属性不变 //animator不仅是效果,他的属性,位置和大小都会跟着变化。 ViewHelper.setTranslationX(mView, deltaX); // TODO: use an ease-out interpolator or such //这个动画的作用是根据滑动的距离来改变view的透明度,算法大家可以仔细看看,不难理解,而且设计的很巧妙 setAlpha(mView,Math.max(0f,Math.min(1f, 1f - 2f * Math.abs(deltaX)/ mViewWidth))); return true; } break; } } return false; }
在动画结束的时候调用这个方法,来真正删除card
private void performDismiss() {
//要删除view的布局参数
final ViewGroup.LayoutParams lp = mView.getLayoutParams();
//要删除view的高度
final int originalHeight = mView.getHeight();
//这个动画作用是要将origalHeight的值在mAniamtionTime中缩减到1
ValueAnimator animator = ValueAnimator.ofInt(originalHeight, 1)
.setDuration(mAnimationTime);
animator.addListener(new AnimatorListenerAdapter() {
@Override
//这个在所有动画结束后调用
public void onAnimationEnd(Animator animation) {
//这句会调用实现了这个callback接口的onDismiss方法,这个方法我们是在CardStack方法中重写的,其作用就是真正的删除这个Card
mCallback.onDismiss(mView, mToken);
//复原动画层,这段我也有点不是太懂,为什幺要给他复原会去,我删除过这段代码,效果没有什么变化,哪位朋友知道希望可以告诉我一下。。。
setAlpha(mView, 1f);
ViewHelper.setTranslationX(mView, 0);
lp.height = originalHeight;
mView.setLayoutParams(lp);
}
});
//这段代码会更新动画改编的值,所以可以在update监听函数中执行需要处理改值的操作
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
lp.height = (Integer) valueAnimator.getAnimatedValue(); //就是要减少他的高度,一直到1为止
mView.setLayoutParams(lp);
}
});
//开始动画
animator.start();
}
在动画结束的时候会调用callback接口的onDismiss方法,去重写这个方法的CardStack中看看
CardStack getView
cardView.setOnTouchListener(new SwipeDismissTouchListener( cardView, card, new OnDismissCallback() { @Override public void onDismiss(View view, Object token) { //很简单,就是删除这个Card,并通知adapter Card c = (Card) token; // call onCardSwiped() listener c.OnSwipeCard(); cards.remove(c); mAdapter.setItems(mStack, getPosition()); // 通知刷新; mAdapter.notifyDataSetChanged(); } }));
点击被覆盖的Card翻转到最前面详解:
滑动删除正式结束,这个开源组件还有另外一个特色,就是点击后面的Card,会翻转到前面来,接着滑落到最下,其他的则依次上升。
接下来就看这个是怎么实现的
还记得在CardStack中会给card设置onclick的监听吗?
// handle the listener //最后一个Card,也就是最上面的一个,直接和我们交互的一个Card //设置它的点击监听,这里的实现写在具体的子类里面,因为不同的Card的点击操作各不相同 if (i == lastCardPosition) { cardView.setOnClickListener(card.getClickListener()); cardView.setOnLongClickListener(card.getOnLongClickListener()); } //非最后一个Card,也就是不是最上面的card,是被盖住的。 //这里的监听主要是为了点击他漏出来的部分可以让他翻转覆盖到最上面,所以这个监听的具体实现可以写在本类中,因为这个是共通的 else { cardView.setOnClickListener(getClickListener(this, container, i)); cardView.setOnLongClickListener(getOnLongClickListener(this, container, i)); }
看代码前先看下截图,这样可能会容易理解一些
开始看代码,这里是一个点击监听,之后根据点击card的位置不同调用不同的处理方法。有两种情况:
1.点击的是被覆盖在最后面的card则调用onClickFirstCard(cardStack, container, index, views);
2.不是则调用onClickOtherCard(cardStack, container, index, views,last);
private OnClickListener getClickListener(final CardStack cardStack, final RelativeLayout container, final int index) { return new OnClickListener() { @Override public void onClick(View v) { View[] views = new View[container.getChildCount()]; for (int i = 0; i < views.length; i++) { views[i] = container.getChildAt(i); } //last是view数组中card的最后一个,就是在最外面露出来的那个card int last = views.length - 1; if (index != last) { //点击的是最里面的一个card if (index == 0) { onClickFirstCard(cardStack, container, index, views); } //点击的是夹在中间的card else if (index < last) { onClickOtherCard(cardStack, container, index, views, last); } } } }下面我们来分别看看这两个方法是如何处理的
//点击的是覆盖在最里面的card public void onClickFirstCard(final CardStack cardStack, final RelativeLayout frameLayout, final int index, View[] views) { // run through all the cards for (int i = 0; i < views.length; i++) { ObjectAnimator anim = null; //先处理最里面的view,需要让它从最里面一直滑落到最下面 if (i == 0) { //滑落的单个距离,这个距离就是两个card之间的距离 float downFactor = 0; //下面的45F就是两个card之间差的高度,这个高度是根据CardStack的布局计算出来的,如果用自定义的布局话需要重新计算这个差值 if (views.length > 2) { //有几个card就滑落相应个数*每个card相差的高度(这里是45F),就计算出card需要下滑的距离了 downFactor = convertDpToPixel((_45F) * (views.length - 1) - 1); } else { downFactor = convertDpToPixel(_45F); } //使用animator动画,在让view在Y轴上移动刚才计算出需要下滑的高度,这样就改变了card的位置了 anim = ObjectAnimator.ofFloat(views[i], NINE_OLD_TRANSLATION_Y, 0, downFactor); //当然只改变位置还不够,还要有别的处理,所以这里给动画加了监听器 anim.addListener(getAnimationListener(cardStack, frameLayout, index, views[index])); } //这个是倒数第二个view,如果最里面的滑落到最下面,则他需要一升一个位置 else if (i == 1) { //这里为什么是17f,不是45f。因为他有一个最上面的弹起的效果,先上升一些,最后的时候再全升上去 float upFactor = convertDpToPixel(-17f); anim = ObjectAnimator.ofFloat(views[i], NINE_OLD_TRANSLATION_Y, 0, upFactor); } //其余的一次上升一个card间相差的单位 else { float upFactor = convertDpToPixel(-1 * _45F); anim = ObjectAnimator.ofFloat(views[i], NINE_OLD_TRANSLATION_Y, 0, upFactor); } if (anim != null) //开始动画 anim.start(); } }这里看这个动画监听器里的实现,看看移动完card的位置后还需要什么操作
private AnimatorListener getAnimationListener(final CardStack cardStack, final RelativeLayout frameLayout, final int index, final View clickedCard) { return new AnimatorListener() { //动画开始时先触发这个监听回调方法 @Override public void onAnimationStart(Animator animation) { //如果idex=0,则表示移动的是最里面的card //如果移动最里面的card的话,之前倒数第二个就会升上去变成第一个,这里要对倒数第二个card做一些布局上的操作 if (index == 0) { View newFirstCard = frameLayout.getChildAt(1); newFirstCard.setBackgroundResource(com.fima.cardsui.R.drawable.card_background); RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(MATCH_PARENT , WRAP_CONTENT); int top = 0; int bottom = 0; top = 2 * Utils.convertDpToPixelInt(mContext, 8) + Utils.convertDpToPixelInt(mContext, 1); bottom = Utils.convertDpToPixelInt(mContext, 12); lp.setMargins(0, top, 0, bottom); newFirstCard.setLayoutParams(lp); newFirstCard.setPadding(0, Utils.convertDpToPixelInt(mContext, 8), 0, 0); } else { clickedCard .setBackgroundResource(com.fima.cardsui.R.drawable.card_background); } //这里是最最关键得操作,大家知道为什么要先删除这个card视图再加回来吗? //目的就是为了实现点击card后先使覆盖在里面的card先“弹”出来,之后在执行动画滑下去。 //所以先把视图删除,再添加这样这个card的视图就在最外面了,多么巧妙的设计哈 frameLayout.removeView(clickedCard); frameLayout.addView(clickedCard); } //动画结束时触发的监听回调方法 @Override public void onAnimationEnd(Animator animation) { //之前只是把视图的位置移动了,但是真正card在集合里的物理位置并没有改变 //所以和视图同样的操作,先删除再添加,这时card位置就到最后面了 Card card = cardStack.remove(index); cardStack.add(card); //重设 刷新 mAdapter.setItems(cardStack, cardStack.getPosition()); mAdapter.notifyDataSetChanged(); } }; }onClickOtherCard方法和onClickFirstCard方法差不多,就不带着看了。
这个开源组件的几个特点我都介绍完毕了。其实发现了不少这个组件的缺点,网上有个更好的card类型的开源组件,叫做cardslib。但是这个工程太大了,不太适合研究学习。但是他的架构和效果确实比这个好多了。
所以我最近正在看这个开源项目,它里面有很多card的一些其他功能比如删除后恢复,点击扩展card视图等等。
我准备把这些功能都加入到现在这个开源组件中,并且尽量优化一下,因为这个组件一共才10多个类,完全可以把几个关键的类拷贝到项目中从而省去导入lib的麻烦。如果这篇文章受欢迎的话,我会尽早做出来并分享出来。
第一次写技术blog,写了好几天,写到后来发现前面写的过于墨迹,注解太多了,所以后面我就尽量只标注关键的地方,如果大家有看不懂的可以留言,有错误也希望大家指出。最后希望大家多多支持哈