Android魔法系列:
http://blog.csdn.net/column/details/17315.html

项目的github地址:FastWidget4Android 很多炫酷的自定义效果,欢迎fork和star!

本篇文章主要去实现一个对折页面的效果,主要来学习Android中的截屏、Bitmap处理及canvas绘制这些知识。
实现后的效果如下


由于有几个效果处理手法类似,可以看成一个系列,所以整理了一些公共的接口和类,本篇文章会仔细介绍一下,故篇幅很能会较长一些,可能也会枯燥一点。

首先,我们不仅仅要实现对折的效果,实际上整体可以看成是一个特殊的ViewPager,每个Item都占满屏幕,而且切换Item时是对折效果。生活中更贴近的例子应该是挂历,一页页的上翻下翻。

所以对折效果是切换时的过渡效果,我们首先要实现这种ViewPager —— AnimationListView,然后再添加上效果。
AnimationListView这个类代码较多,这里就不整个贴出来了,大家可以去项目源码中查看,这里只将关键部分代码讲解一下。
AnimationListView很多思想类似ViewPager,使用了Adapter来加载每个页面,并且缓存了三个页面:当前页面、上个页面和下个页面,这样提前缓存可以让页面表现的更流畅。这部分代码如下:

/** * 设置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重置显示。这样就显示了下一页内容,同时也缓存了再下一页的内容。

Ok,下面我们来研究一个切换时的操作。
由于这个切换不仅仅是一个动画,整个效果实际上是跟着手指滑动而改变的,所以需要处理touch事件,代码如下:

@Overridepublic 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事件。

下面我们来真正的实现对折效果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实现一个百叶窗的效果。

如何实现对折效果?其实整个对折的效果中分为三个区域,如图



其中区域1绘制处于前端的页面的上部分,区域2则绘制处于后端页面的下部分,并且这两个区域是不会做任何改变的。
而区域3较复杂,也是这个效果的关键,如果处于下半部分则绘制前端页面的下半部分,处于上半部分则绘制后端页面的上半部分,并且做了梯形变形实现近大远小的效果。区别如图:



这一就产生了折页的效果,而且区域3需要移动并改变梯形大小来实现移动的效果和动画。
其实还有一个区域,即阴影区域,其位置根据区域3的位置而改变,并且阴影的透明度也要随着改变。
绘制代码如下:

protected void onDraw(Canvas canvas) {    if (mFrontBitmap == null || mBackBitmap == 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;    if(mCurrentPercent < 0){        topBitmap = mFrontBitmap;        bottomBitmap = mBackBitmap;    }    else if(mCurrentPercent > 0){        topBitmap = mBackBitmap;        bottomBitmap = mFrontBitmap;    }    if (topBitmap == null || bottomBitmap == null) {        return;    }    /**     * 在上半部分绘制topBitmap的上半     */    Rect topHoldSrc = new Rect(0, 0, topBitmap.getWidth(), topBitmap.getHeight() / 2);    Rect topHoldDst = new Rect(0, 0, getWidth(), getHeight() / 2);    canvas.drawBitmap(topBitmap, topHoldSrc, topHoldDst, null);    /**     * 在下半部分绘制bottomBitmap的下半     */    Rect bottomHoldSrc = new Rect(0, bottomBitmap.getHeight() / 2, bottomBitmap.getWidth(), mBackBitmap.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;    int startY = 0;    if (mFolioY >= getHeight() / 2) {        //当翻转位置在中部偏下时,取mTopBitmap的下半部分,同时绘制区域为一个正梯形        mFolioBitmap = topBitmap;        startY = mFolioBitmap.getHeight() / 2;        folioDst = new float[]{0, getHeight() / 2,                getWidth(), getHeight() / 2,                rate * FOLIO_SCALE * getWidth() + getWidth(), mFolioY,                -rate * FOLIO_SCALE * getWidth(), mFolioY};    } else {        //当翻转位置在中部偏上时,取mBottomBitmap的上半部分,同时绘制区域为一个倒梯形        mFolioBitmap = bottomBitmap;        startY = 0;        folioDst = new float[]{                -rate * FOLIO_SCALE * getWidth(), mFolioY,                rate * FOLIO_SCALE * getWidth() + getWidth(), mFolioY,                getWidth(), getHeight() / 2,                0, getHeight() / 2        };    }    mFolioBitmap = Bitmap.createBitmap(mFolioBitmap, 0, startY, mFolioBitmap.getWidth(), mFolioBitmap.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这个参数并且重绘页面时就可以产生移动的效果了。
通过之前的分析我们知道,整个移动过程实际上有两个阶段:手动和自动。手动阶段跟随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() {        @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的对应方法实现页面的切换。

总结一下,对折这个效果其实不难,无论绘制还是属性动画,都使用的比较简单。本篇文章更主要的是介绍这样一个框架,在这个框架的基础上,我们之后要实现一些更复杂的效果,比如下一篇的百叶窗效果。

项目的github地址:FastWidget4Android 很多炫酷的自定义效果,欢迎fork和star!

Android魔法系列:

http://blog.csdn.net/column/details/17315.html

联系我

更多相关文章

  1. Eclipse下使用Android(安卓)Design Support Library中的控件
  2. Android(安卓)ViewPager使用详解
  3. Android(安卓)动画各种实现,包括帧动画、补间动画和属性动画的总
  4. Android(安卓)属性动画(一)
  5. htm5 页面跳转在android出现的奇葩问题 【已解决】
  6. 第六章、android的Drawable
  7. Android属性动画上手实现各种动画效果,自定义动画,抛物线等
  8. Android中moveTo、lineTo、quadTo、cubicTo、arcTo详解(实例)
  9. Android流量抓包工具--PacketCapture

随机推荐

  1. ADB 自制android万用驱动方法,解决找不到
  2. Mac OS X系统下的Android环境变量配置
  3. Andorid监听SoftKeyboard弹起事件
  4. Android开发之如何获取Android手机屏幕的
  5. Unity和Android交互(持续更新)
  6. android camera 2
  7. Android(安卓)安全访问机制
  8. android 自适应 多屏幕支持 --Android多
  9. 将服务器端字符读取至android的文本控件,
  10. 在英特尔® 架构平台上开发和优化基于 ND