严振杰

他说正直刚烈,嫉恶如仇,且有勇有谋,文武双全。

Ultra-Pull-To-Refresh 自定义下拉刷新视差动画

版权声明:转载必须注明本文转自严振杰的博客:http://blog.yanzhenjie.com

下拉刷新视差动画也是这几天公司的一个动画,今晚终于不用加班了,加上好多小伙伴问我这个效果,就把这个动画用博客的形式介绍给大家吧,对了如果你想和我交流更多,可以加我博客联系方式中的QQ群。

首先要说明,今天讲的是自定义下拉刷新动画,不是下拉刷新框架怎么写,所以就算不是你想要的,你看看也无防哈哈哈哈……


效果刷新

下拉刷新动画演示

Ultra-Pull-To-Refresh下拉刷新库的介绍

Ultra-Pull-To-Refresh这个下拉刷新库是秋百万(廖祜秋)写的,源代码托管在Github:
https://github.com/liaohuqiu/android-Ultra-Pull-To-Refresh

推荐这个库是一方面是因为PullToRefresh的停止更新,另一方面是Ultra-Pull-To-Refresh的合理设计,满足了我所有的幻想,它唯一的不足是:当顶部嵌套类似ViewPager这种左右滑动的View时下拉刷新会变的很灵敏,多用户体验不太好,不过这一点我已经给出了一个临时解决方案,如果要知道详情请移步此博客:
http://blog.csdn.net/yanzhenjie1003/article/details/51319181

不过今天的博客中的库我已经把修复了的源码附上了,所以大家也可以看完本文后直接下载所有源代码。

最后关于这个库的设计和理解我就不多说了,大家直接看秋百万的原文:
https://android-ultra-ptr.liaohuqiu.net/cn/

自定义动画的分析

首先是Ultra-Pull-To-Refresh的特点,此库提供了一个Layout类:PtrFramLayout作为Wrapper来包涵ContentView,今天用到两个方法:第一个PtrFramLayout#setHeaderView(View)用来设置头部显示的刷新View,第二个PtrFramLayout#addPtrUIHandler(PtrHandler)用来设置监听用户下拉状态、下拉offset、刷新完成状态等。

其次是动画的,根据效果图,第一点是下拉的时候人物从左侧走过来到中间,到中间后手指再继续往下拉,此时人物也不走了,第二点是当手指松开时或者处于下拉状态时,人物不停的走动,并且背景产生一个相对位移,给人的视觉上造成一个视觉差,也就是我们想要的视差动画了,这就是整个视差动画的实现步骤。

那么几个动画拆分开来就是,人物向右中间移动、人物原地踏步、背景无限向左移动。

头View和刷新Layout的实现

我把实现步骤分开讲解,方便读者理解:

  1. 实现自定义的头View。
  2. 继承PtrFramLayout实现一个ParallaxPtrFrameLayout,设置自定头和PtrHandler监听下拉动作。
  3. 实现人物向左走的动画。
  4. 松开手时背景不停的向右移动,人物在原地迈步,形成一个视差上的向右走的动画。

自定义头部View

头View的底下是这样一个图:
头View背景

那么一个图是如何做到不停的向左移动还是无限重复的呢?用HTML做很简单,但是Android中并没有repeat这样的属性,于是我们想到:在屏幕上放一个ImageView向左移动100%,在这张图的右侧再放一个ImageView,以同样的速度向左移动100%,结果就是当屏幕上的图移动到左边外屏幕的时候,屏幕右边的图刚好移动到屏幕上完全显示,然后我们的动画又有重复播放的属性,结合起来就产生了一个背景无限长的动画效果。对于人物原地踏步就很简单了,直接用一个ImageView不停的切换图形成一个人物在走动的视觉效果。

所以我们用两个ImageView作为背景图来相间向左移动,用一个ImageView不停的切换图模拟人物走动,来达到一个人物走动的视差效果,我打算用FrameLayout来作为头ViewLayout,所以布局用merge包裹了一下:
refresh_parallax.xml

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">

    <ImageView
        android:id="@+id/iv_background_1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:contentDescription="@string/app_name"
        android:src="@drawable/refresh_down_background" />

    <ImageView
        android:id="@+id/iv_background_2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:contentDescription="@string/app_name"
        android:src="@drawable/refresh_down_background" />

    <ImageView
        android:id="@+id/iv_refresh_icon"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:contentDescription="@string/app_name"
        android:scaleType="center" />

</merge>

然后定义头View加载刚才写好的布局,因为PtrFrameLayout是通过PtrHandler接口来监听下拉状态和刷新状态,然后以状态为依据来刷新头View的动画,所以头View直接实现PtrHandler接口,然后操作自身的状态和动画也就更加方便了,所以头View初步的代码是:
ParallaxHeader

public class ParallaxHeader extends FrameLayout implements PtrUIHandler {

    ImageView mIvBack1;
    ImageView mIvBack2;
    ImageView mIvIcon;

    private void initialize() {
        // 加载刚才的
        LayoutInflater.from(getContext()).inflate(R.layout.refresh_parallax, this);

        // 设置一个蓝色天空的背景。
        setBackgroundColor(ContextCompat.getColor(getContext(), R.color.refresh_background));

        mIvBack1 = (ImageView) findViewById(R.id.iv_background_1);
        mIvBack2 = (ImageView) findViewById(R.id.iv_background_2);
        mIvIcon = (ImageView) findViewById(R.id.iv_refresh_icon);
    }

    public ParallaxHeader(Context context) {
        this(context, null, 0);
    }

    public ParallaxHeader(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ParallaxHeader(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public void onUIReset(PtrFrameLayout frame) {
        // 重置头View的动画状态,一般停止刷新动画。
    }

    @Override
    public void onUIRefreshPrepare(PtrFrameLayout frame) {
        // 准备刷新的UI。
    }

    @Override
    public void onUIRefreshBegin(PtrFrameLayout frame) {
        // 开始刷新的UI动画。
    }

    @Override
    public void onUIRefreshComplete(PtrFrameLayout frame) {
        // 刷新完成,停止刷新动画。
    }

    @Override
    public void onUIPositionChange(PtrFrameLayout frame, boolean isUnderTouch, byte status, PtrIndicator ptrIndicator) {
        // 手指下拉的时候的状态,我们的下拉动画的控制就是通过这个方法:
        // frame是刷新的root layout。
        // isUnderTouch是手指是否按下,因为还有自动刷新,手指肯定是松开状态。
        // status是现在的加载状态,准备、加载中、完成:PREPARE、LOADING、COMPLETE。
        // ptrIndicator是一些下拉偏移量的参数封装。
    }
}

里面的代码很简单,就是加载刚才定义好的头View对应的Layout.xml文件,然后把两个背景View和人物View给找出来。头View定义好了,接下来定义刷新的Layout

实现ParallaxPtrFrameLayout加载头View

Ultra-Pull-To-Refresh的刷新Layout都是继承PtrFramLayout,然后设置头View和刷新状态监听等,所以我们定义一个ParallaxPtrFrameLayout继承PtrFrameLayout,在里面设置头ViewPtrHandler等来回调操作的头View的动画,很简单的几行代码:

public class ParallaxPtrFrameLayout extends PtrFrameLayout {

    public ParallaxPtrFrameLayout(Context context) {
        super(context);
        initViews();
    }

    public ParallaxPtrFrameLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        initViews();
    }

    public ParallaxPtrFrameLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        initViews();
    }

    private void initViews() {
        // 这里初始化上面的头View:
        ParallaxHeader parallaxHeader = new ParallaxHeader(getContext());

        // 这里设置头View为上面自定义的头View:
        setHeaderView(parallaxHeader);

        // 下拉和刷新状态监听:
        // 因为ParallaxHeader已经实现过PtrUIHandler接口,所以直接设置为ParallaxHeader:
        addPtrUIHandler(parallaxHeader);
    }
}

由于Ultra-Pull-To-Refresh的合理设计,到这里为止,我们的头View和刷新的Layout就完成了,接下来就专心研究动画吧。

动画的实现

上文也提过了,这里的动画拆分开几个,一是下拉的时候人物向右中间移动,二是刷新的时候人物不停的原地踏步,三是刷新的时候背景一个向左平移,为了方便理解,这里把下拉时候人物向右中间移动放到最后来讲。

一、人物原地踏步动画

首先想到的就是帧动画,没错就是这家伙,用帧动画可以做到每多少时间换一张图片,所以我们的人物有三张不同的动画,不停的切换就形成了一个人物走动并车轮转动的效果:
icon1
icon2
icon3

我们用帧动画控制每张图显示100毫秒,然后就切换下一张图,这样便达到我们说的人物走动的效果了,用xml来实现:

<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
    android:oneshot="false">
    <item
        android:drawable="@drawable/refresh_down_icon_1"
        android:duration="100" />
    <item
        android:drawable="@drawable/refresh_down_icon_2"
        android:duration="100" />
    <item
        android:drawable="@drawable/refresh_down_icon_3"
        android:duration="100" />
</animation-list>

因为这是一个帧动画,需要在代码中触发,所以我们要把这个动画放在drawable文件夹,并且把这个drawable当图片设置头View中的人物ImageView

<ImageView
    android:id="@+id/iv_refresh_icon"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom"
    android:scaleType="center"
    android:src="@drawable/refresh_down_icon" />

二、背景无限向左移动

这个动画就厉害了word哥,当时我先做出来,然后给iOS的同学讲实现的原理,他还是花了点时间来理解的,所以我再费点口舌解释一下。

这里是两个ImageView,一个在屏幕正中央,并且占据整屏宽,一个在屏幕外的右侧,宽度等于屏幕宽度。动画开始时,屏幕上的ImageView开始一步步向左移动100%,屏幕之外的ImageView以同样的速度向左移动100%,当屏幕上的ImageView移动到左边外屏幕的时候,屏幕右边的图刚好移动到屏幕上完全显示,然后我们的动画又有重复播放的属性,结合起来就产生了一个背景无限长的动画效果。

为了方便大家理解,我画了一张图:

背景动画
图是画的有点简陋了,但是很好理解,当头View刚出来的时候只显示ImageView1,当刷新的时候ImageView1ImageView2同时向左移动,看起来就是连贯的一张图(实际xml中两张图是没有空隙的),等ImageView1移出屏幕时,ImageView2刚好充斥满屏幕,然后我们给动画加上重复播放属性,然后又从图1开始重复到图三,就形成了一个无限向左移动的街道。

所以我们给第一张图的动画是,在2S内,匀速移动,从屏幕上移动到屏幕左外边,然后再次重复动作:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:anim/linear_interpolator">
    <translate
        android:duration="2000"
        android:fromXDelta="0%"
        android:interpolator="@android:anim/linear_interpolator"
        android:repeatCount="infinite"
        android:repeatMode="restart"
        android:toXDelta="-100%" />
</set>

我们给第二张图的动画是,在2S内,匀速移动,从屏幕右外边移动到屏幕上,然后再次重复动作:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:interpolator="@android:anim/linear_interpolator">
    <translate
        android:duration="2000"
        android:fromXDelta="100%"
        android:interpolator="@android:anim/linear_interpolator"
        android:repeatCount="infinite"
        android:repeatMode="restart"
        android:toXDelta="0%" />
</set>

三个动画到这里就定义完了,接下来就是怎么控制动画了。

三、动画和下拉动作、刷新状态的结合

要控制动画就要把三个动画加载出来,我们继续回到头View``ParallaxHeader中。

首先要加载人物的动画,因为是帧动画,所以要用到AnimationDrawable

private AnimationDrawable mAnimationDrawable;

private void initialize() {
    ...
    mIvIcon = (ImageView) findViewById(R.id.iv_refresh_icon);

    mAnimationDrawable = (AnimationDrawable) mIvIcon.getDrawable();
}

然后加在两个背景的位移动画:

private AnimationDrawable mAnimationDrawable;
private Animation mBackAnim1;
private Animation mBackAnim2;

private void initialize() {
    ...
    mIvIcon = (ImageView) findViewById(R.id.iv_refresh_icon);

    mAnimationDrawable = (AnimationDrawable) mIvIcon.getDrawable();
    mBackAnim1 = AnimationUtils.loadAnimation(getContext(), R.anim.refresh_down_background_1);
    mBackAnim2 = AnimationUtils.loadAnimation(getContext(), R.anim.refresh_down_background_2);
}

接着为了方便调用,也减少代码逻辑的复杂度,我们需要定义两个方法来控制动画的结束和开始,同时为了动画不被重复开始和停止,定义一个变量来记录动画是否是运行的:

/**
 * 记录动画是否在执行。
 */
private boolean isRunAnimation = false;

/**
 * 开始刷新动画。
 */
private void startAnimation() {
    if (!isRunAnimation) {
        isRunAnimation = true;
        mIvBack1.startAnimation(mBackAnim1);
        mIvBack2.startAnimation(mBackAnim2);
        mIvIcon.setImageDrawable(mAnimationDrawable);
        mAnimationDrawable.start();
    }
}

/**
 * 停止刷新动画。
 */
private void stopAnimation() {
    if (isRunAnimation) {
        isRunAnimation = false;
        mIvBack1.clearAnimation();
        mIvBack2.clearAnimation();
        mAnimationDrawable.stop();
    }
}

到这里基本上已经完成了,我们可以把上面PrallaxHeader中的下拉监听和刷新状态代码补全了:

    @Override
    public void onUIReset(PtrFrameLayout frame) {
        // 重置头View的动画状态,一般停止刷新动画。
        stopAnimation();
    }

    @Override
    public void onUIRefreshPrepare(PtrFrameLayout frame) {
        // 准备刷新的UI。
    }

    @Override
    public void onUIRefreshBegin(PtrFrameLayout frame) {
        // 开始刷新的UI动画。
        stopAnimation();
        startAnimation();
    }

    @Override
    public void onUIRefreshComplete(PtrFrameLayout frame) {
        // 刷新完成,停止刷新动画。
        stopAnimation();
    }

    @Override
    public void onUIPositionChange(PtrFrameLayout frame, boolean isUnderTouch, byte status, PtrIndicator ptrIndicator) {
        // 手指下拉的时候的状态,我们的下拉动画的控制就是通过这个方法:
        // frame是刷新的root layout。
        // isUnderTouch是手指是否按下,因为还有自动刷新,手指肯定是松开状态。
        // status是现在的加载状态,准备、加载中、完成:PREPARE、LOADING、COMPLETE。
        // ptrIndicator是一些下拉偏移量的参数封装。
    }

onUIRefreshPrepare()是准备UI,这里不需要实现,除了最后一个方法在下拉的时候触发外,其它都已经实现了,如果你基础还过关,你可以照着本博客敲出来上面讲的所有代码,然后在你的Layout中用一个ParallaxPtrFrameLayout包涵一个布局,运行起来,然后下拉后松开看看,已经看到了文章开头刷新状态时的背景左移,人物走路的动画了。

不过我是个追求完美的人,所以我必须要实现下拉的时候人物走向中间的动画。

四、下拉时,人物走向中间

不可避免,这里我们要拿到下拉时的总offset,还要拿到手指已经下拉的offset,然后算出一个百分比,结合从屏幕最左边到屏幕中间的位置,算出当前人物需要走到哪里。

这里有一个注意的点,就是人物要走到屏幕中间的位置,这个位置可不是屏幕宽度/2,应该等于屏幕宽度/2 - 人物View宽度/2。因为人物是从屏幕最左边x=0开始移动,如果移动到x=屏幕宽/2这个位置,那么人物就看起来偏右了。好吧说这么多,不如再来个图解释一下:

人物位移

这里的问号代表的是Y,这个不用关心,我们只需要关心X方向的平移,这里人物ImageView的X是以左边开始算的,让它移动到屏幕中间的时候它就会是图一所示,此时如果我们将人物ImageView向左移动半个人的距离,刚好是到屏幕中间,所以人物每次需要移动的距离是(屏幕宽度/2 - 人物View宽度/2)。

那么下面我们把代码撸起:

/**
 * 人物到屏幕中间的x点。
 */
private int limitX;

/**
 * 计算人物到屏幕中间的x点。
 */
private void calcLimitX() {
    limitX = DisplayUtils.screenWidth / 2;
    int mIconIvWidth = mIvIcon.getMeasuredWidth();
    limitX -= (mIconIvWidth / 2);
}

@Override
public void onUIPositionChange(PtrFrameLayout frame, boolean isUnderTouch, byte status, PtrIndicator ptrIndicator) {
    // 获取总的头部可下拉的距离:
    final int offsetToRefresh = frame.getOffsetToRefresh();
    // 获取当前手指已经下拉的距离:
    final int currentPos = ptrIndicator.getCurrentPosY();

    // 当前距离小于总的下拉距离时才计算移动
    if (currentPos <= offsetToRefresh && !isRunAnimation) {
        // 计算人物到屏幕中间的x点。
        calcLimitX();

        // 根据下拉距离占可下拉高度的比例,算出向右走的距离:
        double percent = (double) currentPos / offsetToRefresh;
        int targetX = (int) (limitX * percent);
        // 人物向右走:
        mIvIcon.setTranslationX(targetX);

        // 人物向右移动算出来还不够,因为还有换图片才能模拟出人物走动的效果。
        // 当百分比是10 30 50 70 90时显示第一张图。
        // 当百分比是20 40 60 80 100时显示第二张图。
        // 当百分比是5 15 25 35 45 55 65 75 85 95时显示第三张图。
        // 这样就模拟出了下拉时人物向右走的效果了。
        int newPercent = (int) (percent * 100);
        if (newPercent % 10 == 0) {
            double i = newPercent / 10;
            if (i % 2 == 0) {
                mIvIcon.setImageResource(R.drawable.refresh_down_icon_3);
            } else {
                mIvIcon.setImageResource(R.drawable.refresh_down_icon_1);
            }
        } else if (newPercent % 5 == 0) {
            mIvIcon.setImageResource(R.drawable.refresh_down_icon_2);
        }
    }
}

这里废话就再不多说了,一切都在代码注释中,所以下面贴出ParallaxHeader的完整代码,本文源码下载链接在文章末尾:

public class ParallaxHeader extends FrameLayout implements PtrUIHandler {

    ImageView mIvBack1;
    ImageView mIvBack2;
    ImageView mIvIcon;

    private Animation mBackAnim1;
    private Animation mBackAnim2;
    private AnimationDrawable mAnimationDrawable;
    private boolean isRunAnimation = false;
    private int limitX;

    private void initialize() {
        LayoutInflater.from(getContext()).inflate(R.layout.refresh_parallax, this);
        setBackgroundColor(ContextCompat.getColor(getContext(), R.color.refresh_background));

        mIvBack1 = (ImageView) findViewById(R.id.iv_background_1);
        mIvBack2 = (ImageView) findViewById(R.id.iv_background_2);
        mIvIcon = (ImageView) findViewById(R.id.iv_refresh_icon);

        mAnimationDrawable = (AnimationDrawable) mIvIcon.getDrawable();
        mBackAnim1 = AnimationUtils.loadAnimation(getContext(), R.anim.refresh_down_background_1);
        mBackAnim2 = AnimationUtils.loadAnimation(getContext(), R.anim.refresh_down_background_2);
    }

    public ParallaxHeader(Context context) {
        this(context, null, 0);
    }

    public ParallaxHeader(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ParallaxHeader(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initialize();
    }

    /**
     * 开始刷新动画。
     */
    private void startAnimation() {
        if (!isRunAnimation) {
            isRunAnimation = true;
            mIvBack1.startAnimation(mBackAnim1);
            mIvBack2.startAnimation(mBackAnim2);
            mIvIcon.setImageDrawable(mAnimationDrawable);
            mAnimationDrawable.start();
        }
    }

    /**
     * 停止刷新动画。
     */
    private void stopAnimation() {
        if (isRunAnimation) {
            isRunAnimation = false;
            mIvBack1.clearAnimation();
            mIvBack2.clearAnimation();
            mAnimationDrawable.stop();
        }
    }

    @Override
    public void onUIReset(PtrFrameLayout frame) {
        stopAnimation();
    }

    @Override
    public void onUIRefreshPrepare(PtrFrameLayout frame) {
    }

    @Override
    public void onUIRefreshBegin(PtrFrameLayout frame) {
        stopAnimation();
        startAnimation();
    }

    @Override
    public void onUIRefreshComplete(PtrFrameLayout frame) {
        stopAnimation();
    }

    @Override
    public void onUIPositionChange(PtrFrameLayout frame, boolean isUnderTouch, byte status, PtrIndicator ptrIndicator) {
        final int offsetToRefresh = frame.getOffsetToRefresh();
        final int currentPos = ptrIndicator.getCurrentPosY();

        if (currentPos <= offsetToRefresh && !isRunAnimation) {
            if (limitX == 0) calcLimitX();

            double percent = (double) currentPos / offsetToRefresh;
            int targetX = (int) (limitX * percent);
            mIvIcon.setTranslationX(targetX);

            int newPercent = (int) (percent * 100);

            if (newPercent % 10 == 0) {
                double i = newPercent / 10;
                if (i % 2 == 0) {
                    mIvIcon.setImageResource(R.drawable.refresh_down_icon_3);
                } else {
                    mIvIcon.setImageResource(R.drawable.refresh_down_icon_1);
                }
            } else if (newPercent % 5 == 0) {
                mIvIcon.setImageResource(R.drawable.refresh_down_icon_2);
            }
        }
    }

    private void calcLimitX() {
        limitX = DisplayUtils.screenWidth / 2;
        int mIconIvWidth = mIvIcon.getMeasuredWidth();
        limitX -= (mIconIvWidth / 2);
    }
}

这会是凌晨两点,瞌睡的要死要死,大家晚安咯。

下载源码的同学注意,源码中ParallaxHeader最后一个方法的这行代码少了&& !isRunAnimation判断,自行加上即可:

if (currentPos <= offsetToRefresh && !isRunAnimation) {

源码下载传送门: http://download.csdn.net/detail/yanzhenjie1003/9701130;


版权声明:转载必须注明本文转自严振杰的博客:http://blog.yanzhenjie.com

阅读更多

扫码向博主提问

去开通我的Chat快问

yanzhenjie1003

博客专家

学而不思则罔,思而不学则殆。
  • 擅长领域:
  • Android
  • JavaEE
版权声明:转载必须注明本文转自严振杰的博客: http://blog.yanzhenjie.com https://blog.csdn.net/yanzhenjie1003/article/details/53450488
个人分类: 【Android】所有
上一篇Android调试大法 自定义IDE默认签名文件
下一篇Freeline 让AndroidStudio快的飞起来
想对作者说点什么? 我来说一句

三种下拉刷新动画特效

2014年10月04日 3.41MB 下载

没有更多推荐了,返回首页

关闭
关闭