Android魔法(第三弹)—— 一步步实现对折页面
1、效果展示
實現后的效果如下2、AnimationListView框架解讀
1)框架產生原因
由于有幾個效果處理手法類似,可以看成一個系列,所以整理了一些公共的接口和類,本篇文章會仔細介紹一下,故篇幅很能會較長一些,可能也會枯燥一點。 首先,我們不僅僅要實現對折的效果,實際上整體可以看成是一個特殊的ViewPager,每個Item都占滿屏幕,而且切換Item時是對折效果。生活中更貼近的例子應該是掛歷,一頁頁的上翻下翻。 所以對折效果是切換時的過渡效果,我們首先要實現這種ViewPager —— AnimationListView,然后再添加上效果。 AnimationListView這個類代碼較多,這里就不整個貼出來了,大家可以去項目源碼中查看,這里只將關鍵部分代碼講解一下。2)實現頁面緩存
AnimationListView很多思想類似ViewPager,使用了Adapter來加載每個頁面,并且緩存了三個頁面:當前頁面、上個頁面和下個頁面,這樣提前緩存可以讓頁面表現的更流暢。這部分代碼如下: /***?設置adapter,設置監聽并重新布局頁面*?@param?adapter*/ public?void?setAdapter(Adapter?adapter)?{mAdapter?=?adapter;mAdapter.registerDataSetObserver(new?DataSetObserver()?{@Overridepublic?void?onChanged()?{super.onChanged();refreshByAdapter();}@Overridepublic?void?onInvalidated()?{super.onInvalidated();refreshByAdapter();}});mCurrentPosition?=?0;refreshByAdapter(); }/***?重新布局頁面*?先添加mCacheItems,再添加mFolioView。這樣mFolioView一直處于頂端,不會被遮擋。*/ private?void?refreshByAdapter()?{removeAllViews();if?(mCurrentPosition?<?0)?{mCurrentPosition?=?0;}if?(mCurrentPosition?>=?mAdapter.getCount())?{mCurrentPosition?=?mAdapter.getCount()?-?1;}//如果緩存item不夠3個,用第一個item添補while(mCacheItems.size()?<?3){View?item?=?mAdapter.getView(0,?null,?null);addView(item,?mLayoutParams);mCacheItems.add(item);}//刷新緩存item的數據。for?(int?i?=?0;?i?<?mCacheItems.size();?i++)?{int?index?=?mCurrentPosition?+?i?-?1;View?item?=?mCacheItems.get(i);//當在列表頂部或底部,會有一個緩存Item不刷新,因為當前位置沒有上一個或下一個位置if?(index?>=?0?&&?index?<?mAdapter.getCount())?{item?=?mAdapter.getView(index,?item,?null);}}//刷新界面initItemVisible();//添加翻轉處理的viewsetAnimationViewVisible(false); }/***?下一頁*/ protected?void?pageNext()?{setAnimationViewVisible(false);//當前位置加1mCurrentPosition++;if?(mCurrentPosition?>=?mAdapter.getCount())?{mCurrentPosition?=?mAdapter.getCount()?-?1;}//移出緩存的第一個item,并且刷新成當前位置的下一位,并添加到緩存列表最后View?first?=?mCacheItems.remove(0);if?(mCurrentPosition?+?1?<?mAdapter.getCount())?{first?=?mAdapter.getView(mCurrentPosition?+?1,?first,?null);}mCacheItems.add(first);//刷新界面initItemVisible(); }/***?上一頁*/ protected?void?pagePrevious()?{//當前位置減1mCurrentPosition--;if?(mCurrentPosition?<?0)?{mCurrentPosition?=?0;}//移出緩存的最后一個item,并且刷新成當前位置的上一位,并添加到緩存列表開始View?last?=?mCacheItems.remove(mCacheItems.size()?-?1);if?(mCurrentPosition?-?1?>=?0)?{last?=?mAdapter.getView(mCurrentPosition?-?1,?last,?null);}mCacheItems.add(0,?last);//刷新界面initItemVisible();setAnimationViewVisible(false); }/***?刷新所有的item,并且只顯示當前位置即中間的item*/ private?void?initItemVisible()?{for?(int?i?=?0;?i?<?mCacheItems.size();?i++)?{View?item?=?mCacheItems.get(i);item.invalidate();if?(item?==?null)?{continue;}if?(i?==?1)?{item.setVisibility(VISIBLE);}?else?{item.setVisibility(INVISIBLE);}} }首先,我們來看refreshByAdpter這個函數,可以看到當adapter的數據有變化時都會調用這個函數,它的作用就是根據當前的position初始化頁面使adpter生效。
在這個函數中,根據當前的position中adapter中獲取了三個(或者兩個,當處于開始或最后時)view緩存起來,并且緩存的三個view都添加到了頁面上。至于為甚么將三個view都添加到頁面中,而不是只添加當前頁面,是因為后面實現切換效果需要,這個后面會解釋到。
當三個view都添加進頁面,可以看到又調用了initItemVisible函數,通過代碼可以看到這個函數主要就是處理三個view的展示。將當前頁面設為VISIBLE,而其他頁面設為INVISIBLE,保證了當前頁面的展示。
最后調用了setAnimationViewVisible函數,這個函數用于展示隱藏處理切換動畫的view,后面會講到。
然后,pageNext和pagePrevious這兩個方法類似,分別實現向上和向下切頁(不包含切換動畫)。以pageNext為例,取出緩存mCacheItems的第一個view,為這個view重新裝載再下一頁的數據,然后添加回mCacheItems尾部,調用initItemVisible重置顯示。這樣就顯示了下一頁內容,同時也緩存了再下一頁的內容。
3)處理touch事件
Ok,下面我們來研究一個切換時的操作。 由于這個切換不僅僅是一個動畫,整個效果實際上是跟著手指滑動而改變的,所以需要處理touch事件,代碼如下: @Override public?boolean?onTouchEvent(MotionEvent?event)?{if?(getWidth()?<=?0?||?getHeight()?<=?0)?{return?false;}//當動畫組件動畫執行中,則忽略touch事件if(mAnimationView?!=?null?&&?mAnimationView.isAnimationRunning()){return?true;}switch?(event.getAction())?{case?MotionEvent.ACTION_DOWN:mTmpX?=?event.getX();mTmpY?=?event.getY();break;case?MotionEvent.ACTION_MOVE:/***?計算移動的距離*?這里加了判斷,是為了防止mMoveX或mMoveY為0,因為后面會根據這倆個判斷移動方向。*/if?(event.getX()?!=?mTmpX)?{mMoveX?=?event.getX()?-?mTmpX;}if?(event.getY()?!=?mTmpY)?{mMoveY?=?event.getY()?-?mTmpY;}//創建動畫組件createAnimationView();/***?計算當前的位置百分比*?0則代表初始位置*?0.x則代表下一頁翻轉的百分比*?1則代表翻到了下一頁。*?-0.x則代表上一頁翻轉的百分比*?-1則代表翻到上一頁。*/float?percent?=?mAnimationView.getAnimationPercent();if?(isVertical)?{percent?+=?mMoveY?/?getHeight();}?else?{percent?+=?mMoveX?/?getWidth();}//保證位置在1到-1之間if(percent?<?-1){percent?=?-1;}else?if(percent?>?1){percent?=?1;}if(canPage(mMoveX,?mMoveY,?percent))?{//如果動畫組件未展示將其展示if?(!isAnimationViewVisible())?{setAnimationViewVisible(true);}//裝載或切換動畫的圖片switchAniamtionBitmap(percent);mAnimationView.setAnimationPercent(percent,?event,?isVertical);}mTmpX?=?event.getX();mTmpY?=?event.getY();break;case?MotionEvent.ACTION_UP:case?MotionEvent.ACTION_CANCEL:case?MotionEvent.ACTION_OUTSIDE:/***?計算移動的距離*?這里加了判斷,是為了防止mMoveX或mMoveY為0,因為后面會根據這倆個判斷移動方向。*/if?(event.getX()?!=?mTmpX)?{mMoveX?=?event.getX()?-?mTmpX;}if?(event.getY()?!=?mTmpY)?{mMoveY?=?event.getY()?-?mTmpY;}/***?計算結束位置百分比*?0則代表初始位置*?1則代表翻到了下一頁。*?-1則代表翻到上一頁。*/float?toPercent?=?0;if?(isVertical)?{toPercent?=?mMoveY?>?0???1?:?0;}?else?{toPercent?=?mMoveX?>?0???1?:?0;}if(mAnimationView.getAnimationPercent()?<?0){//如果是翻上一頁的狀態,則起點終點應該是0和-1toPercent?-=?1;}//如果可以翻頁,則播放翻頁動畫if(canPage(mMoveX,?mMoveY,?toPercent))?{mAnimationView.startAnimation(isVertical,?event,?toPercent);}mMoveX?=?0;mMoveY?=?0;break;}return?true; }這部分是AnimationListView的核心。
首先分析ACTION_MOVE這個狀態。可以看到最開始調用了createAnimationView這個函數,代碼如下:
private?void?createAnimationView(){if(mAnimationView?==?null){try?{Constructor<??extends?AnimationViewInterface>?constructor?=?animationClass.getConstructor(Context.class);mAnimationView?=?constructor.newInstance(getContext());}?catch?(Exception?e)?{e.printStackTrace();}}mAnimationView.setOnAnimationViewListener(new?OnAnimationViewListener()?{@Overridepublic?void?pageNext()?{AnimationListView.this.pageNext();}@Overridepublic?void?pagePrevious()?{AnimationListView.this.pagePrevious();}}); }mAnimationView是一個AnimationViewInterface接口的實現,主要是用于處理和展示切換的動效的。我們這次實現的對折只是其中一種效果而已,對于這個接口和實現,我們后面來講,暫時大家知道這是一個用于展示動效的View就可以了。
由于AnimationViewInterface有多個子類的實現,所以這里使用一種工廠模式,即使用反射根據animationClass來初始化。
回到ACTION_MOVE的代碼,創建成功后先根據滑動的方向判斷是向上還是向下翻頁,并通過移動的距離計算出一個百分比。然后通過一個canPage函數判斷是否可以翻頁,這個函數比較簡單,主要就是判斷是否到開始或結尾了。如何canPage為true,可以看到依次調用了三個函數:setAnimationViewVisible,switchAnimationBitmap和mAnimationView.setAnimationPercent。
首先看setAnimationViewVisible這個函數:
protected?void?setAnimationViewVisible(boolean?visible)?{if(mAnimationView?==?null){return;}if?(visible)?{addView((View)?mAnimationView,?mLayoutParams);}?else?{removeView((View)?mAnimationView);} }上面也提到過這個函數,通過代碼可以看到就是根據visible將一個mAnimationView添加或移除來達到展示隱藏的效果。
調用這個函數就是將mAnimationView添加到屏幕上,并且處于最頂層,覆蓋了當前頁面。
然后是switchAnimationBitmap函數:
private?void?switchAniamtionBitmap(float?percent){//如果當前為初始狀態即未翻轉,或轉變了翻轉方向則需切換背景圖if(mAnimationView.getAnimationPercent()?==?0||?mAnimationView.getAnimationPercent()?*?percent?<?0)?{//前景圖是當前頁面,即緩存頁面中的第二個Bitmap?frontBitmap?=?getViewBitmap(mCacheItems.get(1));Bitmap?backBitmap?=?null;/***?背景圖根據翻轉方向不同改變。*?如果要翻到上一頁,則背景圖為緩存頁面中的第一個*?如果要翻到下一頁,則背景圖為緩存頁面中的第二個*/if?(isVertical)?{backBitmap?=?getViewBitmap(mCacheItems.get(mMoveY?>?0???0?:?2));}?else?{backBitmap?=?getViewBitmap(mCacheItems.get(mMoveX?>?0???0?:?2));}//初始化動畫組件initAniamtionView(frontBitmap,?backBitmap);} }根據翻頁方向的不同,分別對當前頁面和即將翻到的頁面進行截屏,即getViewBitmap函數。這就是前面為什么要將三個緩存的Item都添加到布局中的原因,因為只有添加到屏幕上才能將內容截屏出來。至于為什么要截屏,因為每個Item的布局可能復雜,而在對折這個效果中,我們需要將一個頁面分成兩部分單獨處理效果,這樣直接對Item操作幾乎不可能。所以我們截屏后對Bitmap處理可操作性大很多,這也是為什么mAnimationView一定要在最頂層覆蓋其他View的原因。實際上,當我們進行翻頁時看到的是mAnimationView,而真正的頁面都隱藏在下面。
至于getViewBitmap中如何實現截屏,代碼很簡單,大家看源碼就好了。
取得兩個頁面的截屏設置到mAnimationView中,至于怎么處理這兩個bitmap就在mAnimationView中了,而且有這兩個Bitmap我們可以實現很多很多效果,這也是為什么花這么大篇幅來講解AnimationListView這個類的原因,因為以后我們使用這個類來實現很多不同的效果。
最后是mAnimationView.setAnimationPercent,通過之前計算出來的百分比來設置這一瞬間的效果展示。這個函數不同的子類實現不同,后面再說。
整個ACTION_MOVE過程,根據移動來實時的改變展示。當滑動完成時,由于可能翻頁效果只展示到中間某一點,所以需要啟動一個動畫來實現剩下的效果完成整個翻頁,這就是ACTION_UP狀態中代碼的作用。
這樣AnimationListView這個類主要的功能就解析完成了,主要是實現一個類似ViewPager的View,并且重點處理用戶的touch事件。
3、AnimationViewInterface接口
下面我們來真正的實現對折效果FolioView,首先FolioView要實現AnimationViewInterface這個接口,這個接口代碼如下: public?interface?AnimationViewInterface?{/***?初始化圖片*?@param?frontBitmap?前景圖片*?@param?backBitmap? ?背景圖片*/void?setBitmap(Bitmap?frontBitmap,?Bitmap?backBitmap);boolean?isAnimationRunning();/***?開啟動畫*?從當前狀態到toPercent的狀態*?@param?isVertical*?@param?event*?@param?toPercent?動畫的最終位置百分比*/void?startAnimation(boolean?isVertical,?MotionEvent?event,?float?toPercent);float?getAnimationPercent();/***?設置動畫到某一幀的狀態*?用于滑動過程中實時改變animationview的狀態*?@param?percent?當前處于動畫的位置百分比*?@param?event*?@param?isVertical*/void?setAnimationPercent(float?percent,?MotionEvent?event,?boolean?isVertical);void?setDuration(long?duration);void?setOnAnimationViewListener(OnAnimationViewListener?onAnimationViewListener); }至于這些方法的作用,通過之前的講解基本上都能猜出來了,就不細說了。通過實現這個接口,我們不僅僅可以實現對折效果,實際上由于setBitmap我們得到了兩個bitmap,我們可以利用這兩個bitmap實現任何想要的效果。在下一篇文章中,我會利用AnimationListView和AnimationViewInterface實現一個百葉窗的效果。
4、對折動畫分析
如何實現對折效果?其實整個對折的效果中分為三個區域,如圖 其中區域1繪制處于前端的頁面的上部分,區域2則繪制處于后端頁面的下部分,并且這兩個區域是不會做任何改變的。 而區域3較復雜,也是這個效果的關鍵,如果處于下半部分則繪制前端頁面的下半部分,處于上半部分則繪制后端頁面的上半部分,并且做了梯形變形實現近大遠小的效果。區別如圖: 這一就產生了折頁的效果,而且區域3需要移動并改變梯形大小來實現移動的效果和動畫。 其實還有一個區域,即陰影區域,其位置根據區域3的位置而改變,并且陰影的透明度也要隨著改變。5、對折效果繪制FolioView.onDraw
繪制代碼如下: @Override protected void onDraw(Canvas canvas) {if (mFrontBitmapTop == null || mBackBitmapTop == null) {return;}if(getHeight() <= 0){return;}/*** 計算翻轉的比率* 用于計算圖片的拉伸和陰影效果*/float rate;if (mFolioY >= getHeight() / 2) {rate = (float) (getHeight() - mFolioY) * 2 / getHeight();} else {rate = (float) mFolioY * 2 / getHeight();}/*** 根據上翻下翻判斷上下的圖片*/Bitmap topBitmap = null;Bitmap bottomBitmap = null;Bitmap topBitmapFolie = null;Bitmap bottomBitmapFolie = null;if(mCurrentPercent < 0){topBitmap = mFrontBitmapTop;bottomBitmap = mBackBitmapBottom;topBitmapFolie = mFrontBitmapBottom;bottomBitmapFolie = mBackBitmapTop;}else if(mCurrentPercent > 0){topBitmap = mBackBitmapTop;bottomBitmap = mFrontBitmapBottom;topBitmapFolie = mBackBitmapBottom;bottomBitmapFolie = mFrontBitmapTop ;}if (topBitmap == null || bottomBitmap == null) {return;}/*** 在上半部分繪制topBitmap*/Rect topHoldSrc = new Rect(0, 0, topBitmap.getWidth(), topBitmap.getHeight());Rect topHoldDst = new Rect(0, 0, getWidth(), getHeight() / 2);canvas.drawBitmap(topBitmap, topHoldSrc, topHoldDst, null);/*** 在下半部分繪制bottomBitmap*/Rect bottomHoldSrc = new Rect(0, 0, bottomBitmap.getWidth(), bottomBitmap.getHeight());Rect bottomHoldDst = new Rect(0, getHeight() / 2, getWidth(), getHeight());canvas.drawBitmap(bottomBitmap, bottomHoldSrc, bottomHoldDst, null);/*** 繪制陰影* 陰影與翻轉是在同一區域,并且根據翻轉程度改變*/Paint shadowP = new Paint();shadowP.setColor(0xff000000);shadowP.setAlpha((int) ((1 - rate) * FOLIO_SHADOW_ALPHA));if (mFolioY >= getHeight() / 2) {canvas.drawRect(bottomHoldDst, shadowP);} else {canvas.drawRect(topHoldDst, shadowP);}/*** 繪制翻轉效果的圖片* 翻轉圖片是一個梯形,根據情況梯形大小位置等不相同*/mFolioBitmap = null;float[] folioSrc = null;float[] folioDst = null;if (mFolioY >= getHeight() / 2) {//當翻轉位置在中部偏下時,取topBitmapFolie,同時繪制區域為一個正梯形mFolioBitmap = topBitmapFolie;folioDst = new float[]{0, getHeight() / 2,getWidth(), getHeight() / 2,rate * FOLIO_SCALE * getWidth() + getWidth(), mFolioY,-rate * FOLIO_SCALE * getWidth(), mFolioY};} else {//當翻轉位置在中部偏上時,取bottomBitmapFolie,同時繪制區域為一個倒梯形mFolioBitmap = bottomBitmapFolie;folioDst = new float[]{-rate * FOLIO_SCALE * getWidth(), mFolioY,rate * FOLIO_SCALE * getWidth() + getWidth(), mFolioY,getWidth(), getHeight() / 2,0, getHeight() / 2};}folioSrc = new float[]{0, 0,mFolioBitmap.getWidth(), 0,mFolioBitmap.getWidth(), mFolioBitmap.getHeight(),0, mFolioBitmap.getHeight()};Matrix matrix = new Matrix();matrix.setPolyToPoly(folioSrc, 0, folioDst, 0, folioSrc.length >> 1);canvas.drawBitmap(mFolioBitmap, matrix, null);super.onDraw(canvas); }可以看到mFolioY這個參數是關鍵,這個參數是是指區域3梯形長邊到頁面頂端的距離。通過這個參數來計算區域3的位置、陰影的大小和梯形的形狀等等。
在繪制過程中,首先繪制區域1和區域2,因為這兩個區域固定不變而且不受其他參數影響。
然后根據mFolioY判斷區域3是在上半部分還是下半部分。先繪制陰影,陰影區域是與區域3在同一部分,采用簡單的方法,完全覆蓋區域1或區域2即可。
然后再去繪制區域3,這樣可以覆蓋陰影部分。通過判斷區域3的位置選用不同的圖片,并且使用Matrix和矩陣將圖片做梯形變形,然后繪制到指定的區域。
這就是整個繪制的過程,當我們改變mFolioY這個參數并且重繪頁面時就可以產生移動的效果了。
6、對折動畫解析
通過之前的分析我們知道,整個移動過程實際上有兩個階段:手動和自動。手動階段跟隨touch的move事件而移動,當touch結束的時候則進行自動動畫。1)手動階段
主要是調用AnimationViewInterface的setAnimationPrecent函數來實現移動,這個函數代碼如下: public?void?setAnimationPercent(float?percent,?MotionEvent?event,?boolean?isVertical)?{if(!isVertical){return;}if(getHeight()?<=?0){return;}/***?計算翻轉的位置*?如果位置超出了區域,則完成翻轉*/mFolioY?=?percent?>?0???percent?*?getHeight()?:?(1?+?percent)?*?getHeight();invalidate();mCurrentPercent?=?percent; }可以看到主要就是通過percent計算出mFolioY,然后重繪。
2)自動階段
調用另外一個函數:startAnmation,如下 public?void?startAnimation(boolean?isVertical,?MotionEvent?event,?final?float?toPercent)?{if(!isVertical){return;}if(getHeight()?<=?0){return;}/***?播放翻轉動畫*?先計算動畫結束的位置,然后設定動畫從當前位置翻到結束點*?動畫的實質上是不停改變翻轉位置并重繪*/float?endPosition?=?0;if?(mCurrentPercent?<?0)?{endPosition?=?toPercent?==?0???getHeight()?:?0;}?else{endPosition?=?toPercent?==?0???0?:?getHeight();}mFolioAnimation?=?ObjectAnimator.ofFloat(this,?"folioY",?endPosition);mFolioAnimation.setDuration((long)(mduration?*?Math.abs(toPercent?-?mCurrentPercent)));mFolioAnimation.addListener(new?Animator.AnimatorListener()?{@Overridepublic?void?onAnimationStart(Animator?animation)?{}@Overridepublic?void?onAnimationEnd(Animator?animation)?{mCurrentPercent?=?0;if(mOnAnimationViewListener?!=?null){if(toPercent?==?1){mOnAnimationViewListener.pagePrevious();}else?if(toPercent?==?-1){mOnAnimationViewListener.pageNext();}}}@Overridepublic?void?onAnimationCancel(Animator?animation)?{}@Overridepublic?void?onAnimationRepeat(Animator?animation)?{}});mFolioAnimation.start(); }先通過toPercent計算endPosition,這個參數是動畫結束時mFolioY的值。
然后啟動一個屬性動畫,通過setter和getter將mFolioY的值從當前值逐漸改變至endPosition。當動畫結束時判斷翻頁方向并調用listener的對應方法實現頁面的切換。
7、總結
總結一下,對折這個效果其實不難,無論繪制還是屬性動畫,都使用的比較簡單。本篇文章更主要的是介紹這樣一個框架,在這個框架的基礎上,我們之后要實現一些更復雜的效果,比如下一篇的百葉窗效果。源碼:
關注公眾號:BennuCTech,發送“FastWidget”獲取完整源碼總結
以上是生活随笔為你收集整理的Android魔法(第三弹)—— 一步步实现对折页面的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 减小TabLayout高度而不影响每个t
- 下一篇: Android魔法(第四弹)—— 一步步