1、效果展示

2、AnimationListView框架解读
1)框架产生原因
2)实现页面缓存
/**
* 设置adapter,设置监听并重新布局页面
* @param adapter
*/
public void setAdapter(Adapter adapter) {
mAdapter = adapter;
mAdapter.registerDataSetObserver(new DataSetObserver() {
@Override
public void onChanged() {
super.onChanged();
refreshByAdapter();
}
@Override
public 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();
//添加翻转处理的view
setAnimationViewVisible(false);
}
/**
* 下一页
*/
protected void pageNext() {
setAnimationViewVisible(false);
//当前位置加1
mCurrentPosition++;
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() {
//当前位置减1
mCurrentPosition--;
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事件
@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和-1
toPercent -= 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() {
@Override
public void pageNext() {
AnimationListView.this.pageNext();
}
@Override
public 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接口
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、对折动画分析



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、对折动画解析
1)手动阶段
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)自动阶段
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() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
mCurrentPercent = 0;
if(mOnAnimationViewListener != null){
if(toPercent == 1){
mOnAnimationViewListener.pagePrevious();
}
else if(toPercent == -1){
mOnAnimationViewListener.pageNext();
}
}
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
mFolioAnimation.start();
}
先通过toPercent计算endPosition,这个参数是动画结束时mFolioY的值。
然后启动一个属性动画,通过setter和getter将mFolioY的值从当前值逐渐改变至endPosition。当动画结束时判断翻页方向并调用listener的对应方法实现页面的切换。
7、总结
源码:
