做了一个下拉刷新的控件

UI Movement上看到一个好看的下拉刷新的控件效果,想着把他实现了.

效果预览

原地址在UI Movement
效果是这样的

下面是我做出来的效果

效果分析

老样子,首先进行效果分析

  1. 这是一个下拉刷新控件
  2. 下拉的过程中从顶部出现很多圆形小点,小点的半径和亮度(透明度)会发生变化,这个变化是和下拉程度无关的.
  3. 随着下拉的进行,小圆点汇聚成一个圆环
  4. 下拉结束时,汇聚成的圆环的半径会形成类似于呼吸灯的大小变化.
  5. 下拉效果是有阻力的,也就是手指移动的距离和控件移动的距离是不一样的.
  6. 在没有下拉形成一个完整圆环的时候松手,小点会散回去

我分了两步来实现这个控件

  1. 下拉的动画,他负责实现具体的动画效果
  2. 下拉控件布局,负责处理触摸事件,对外暴露接口
    也就是说这个这个下拉刷新控件是由一个动画和自定义的viewGroup组合而成的.

下拉动画的实现

我把它叫做HaloRingAnimation,这是一个自定义view.
根据效果分析,需要定义一个点,起始坐标,结束坐标,移动路径,半径,透明度.我专门定义了一个类HaloPoint

private class HaloPoint {
        float pos[] = {0, 0};//坐标,x,y;
        float radius = 8;//点的半径
        Path mPath;//移动路径
        float length;//路径长度
        float endPos[] = {0, 0};//终点位置坐标
        float startPercent = 0f;//可以显示时的比例,确定何时出现
        boolean drawAble = false;//确定是否可现实
        int alpha;//透明度

        void setPos(float x, float y) {
            this.pos[0] = x;
            this.pos[1] = y;
        }

        void setRadius(float radius) {
            this.radius = radius;
        }
    }

在进行初始化的时候,每个点都需要进行初始化,一个圆环360度,可以360个点组成。
每个点的初始位置都在控件的最上层,也就是每个点的初始y坐标为-5(为了出现的效果不那么突兀),而x坐标,我决定使用随机值,每个点的大小,透明度,移动我也使用了随机值.

        for (int i = 0; i < 360; i++) {
                HaloPoint haloPoint = mHaloPoints.get(i);
                haloPoint.setRadius((float) (1 + Math.random() * 3));//初始化点的大小
                setStartPos((float) (Math.random() * width), -5, haloPoint.pos);//初始化点的位置
                setEndPos(width / 2, mRingTop + mRingRadius, -90 + i, haloPoint.endPos);//初始化点移动的结束位置
                haloPoint.mPath = setPath(haloPoint.pos, haloPoint.endPos);//初始化点移动的路径path
                pathMeasure.setPath(haloPoint.mPath, false);//将path进行测量
                haloPoint.length = pathMeasure.getLength();//获取path的总长度
                haloPoint.startPercent = i > 180 ? (1 - (i / 360.0f)) : mHalfPercent / 180 * i;//设置point应该何时出现
                haloPoint.alpha = 100 + (int) (50 * Math.random());//点的变化透明度
            }

这里面有两个地方需要说一下
一是点的最终坐标的确定,这些点的坐标要保证能够形成一个圆

    //确定点移动结束后在圆上的坐标,
    private void setEndPos(int xDot, int yDot, int radius, float pos[]) {
        pos[0] = (float) (xDot + mRingRadius * (Math.cos(radius * Math.PI / 180)));
        pos[1] = (float) (yDot + mRingRadius * (Math.sin(radius * Math.PI / 180)));
    }

二是点的移动路径,点的移动路径使用了path,曲线使用的两段二阶的贝塞尔曲线构成的.而且贝塞尔曲线的取点,也选的是随机的,只有终点位置是确定的,不过也可以通过设置不同的曲线来实现不同的效果.

    //设置点的移动路径
    private Path setPath(float startPos[], float endPos[]) {
        Path path = new Path();
        path.moveTo(startPos[0], startPos[1]);
        path.quadTo((float) (Math.random() * width), (float) (Math.random() * mRingTop), (float) width / 4 + (float) (Math.random() * width / 2), (float) (Math.random() * mRingTop));
        path.quadTo((float) (Math.random() * width), (float) (Math.random() * mRingTop), endPos[0], endPos[1]);
        return path;
    }

为了实现点的大小变化和下拉无关,使用了一个ValueAnimator,用来产生有规律的数字,进行主动刷新重绘

  mPointAlphaRadiusValueAnimator = new ValueAnimator();
        mPointAlphaRadiusValueAnimator.setDuration(800);
        mPointAlphaRadiusValueAnimator.setFloatValues(100);
        mPointAlphaRadiusValueAnimator.setRepeatCount(ValueAnimator.INFINITE);//动画无限进行,
        mPointAlphaRadiusValueAnimator.setRepeatMode(ValueAnimator.REVERSE);//动画的循环方式
        mPointAlphaRadiusValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                timeRationAlpha = (float) animation.getAnimatedValue();//透明度变化
                timeRationRadius = (float) animation.getAnimatedValue() * 0.05f;//圆点半径的变化
                mRingRadius = (int) ((float) animation.getAnimatedValue() * 0.1f + mRingRadiusONLY - 5);//圆环半径的变化
                invalidate();//重绘
            }
        });

可以看到我是在这里进行重绘的.
看一下重要的绘制部分

 @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (!mPullEnd) {//在没有下拉到底部的时候才进行绘制
            for (int i = 0; i < 360; i += 5) {//调整i的间隔数可以实现不同的效果
                HaloPoint haloPoint = mHaloPoints.get(i);
                haloPoint.drawAble = haloPoint.startPercent < mPercent;
                //将需要显示的点显示出来
                if (!haloPoint.drawAble)
                    continue;
                //确定点的位置
                pathMeasure.setPath(haloPoint.mPath, false);
                pathMeasure.getPosTan(haloPoint.length * (mPercent - haloPoint.startPercent) / mHalfPercent, haloPoint.pos, null);
                int alpha = Math.min((int) (haloPoint.alpha + i + timeRationAlpha), 255);
                mPointPaint.setAlpha(alpha);

                float pointRadius;
                //产生不同点在不同时刻大小不断变化的效果
                if (i % 2 == 0)
                    pointRadius = (float) (timeRationRadius * ((Math.cos((mPercent - haloPoint.startPercent) / mHalfPercent * 100))) + 6);
                else
                    pointRadius = (float) (timeRationRadius * (Math.abs(Math.sin((mPercent - haloPoint.startPercent) / mHalfPercent * 100))) + 4);
                //当点移动到位时,透明度,大小还有阴影都不再发生变化
                if ((mPercent - haloPoint.startPercent) / mHalfPercent >= 1) {
                    pointRadius = 7;
                    mPointPaint.setAlpha(255);
                    mPointPaint.clearShadowLayer();
                }
                canvas.drawCircle(haloPoint.pos[0], haloPoint.pos[1], pointRadius, mPointPaint);
            }
        } else {
            //当下拉底部时,画出圆环
            canvas.drawCircle(width / 2, mRingTop + mRingRadiusONLY, mRingRadius, mRingPaint);
        }
    }

这里面重要的一步是如何确定点的位置,也就是确定确定path上某一点的位置,有pathMeasure.getPosTan()这个方法可以用,需要传入三个参数,该点在path上距起点的距离,保存点坐标的一个数组,保存正切值的一个数组.这个距离也可以通过pathMeasure.getLength()来获得.

下拉控件的实现

通过继承FrameLayout,对其进行改写,这一部分参考了官方的SwipeRefreshLayout的实现,还有CircleRefreshLayout的实现.学习到了很多
FrameLayout的布局是最简单的一种布局,就是所有的控件都放在左上角,进行层叠摆放,这也正是我需要的,也就没有对其重写.
真正重写地方有3处
1.addview,为了保证控件中只有一个子view

 @Override
    public void addView(View child) {
        //确保只能添加一个view
        if (getChildCount() >= 1) {
            throw new RuntimeException("you can only attach one child");
        }
        mChildView = child;
        super.addView(child);
    }

2.onInterceptTouchEvent,为了保证触摸事件正确地分发

  public boolean onInterceptTouchEvent(MotionEvent event) {

         if (mIsRefreshing||!mEnableRefresh) {
            return super.onInterceptTouchEvent(event);
        }
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mIsRefreshing = false;
                mInitialDownY = event.getY();
                mIsBeingDragged = false;
                mHeaderView.setEndpull(false);
                break;
            case MotionEvent.ACTION_MOVE:
                float y = event.getY();
                startDragging(y);//判断是否产生有效滑动
                if (mIsBeingDragged)
                    return true;
        }
        return super.onInterceptTouchEvent(event);
    }

3.onTouchEvent,根据触摸效果进行处理.

 @Override
    public boolean onTouchEvent(MotionEvent event) {

        if (mIsRefreshing) {
            return super.onTouchEvent(event);
        }
        int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_MOVE:
                float y = event.getY();
                startDragging(y);

                if (mIsBeingDragged) {
                    //加入滑动阻力,计算出控件应该移动的距离
                    overScrollTop = (y - mInitialMotionY) * dragRatio;
                    if (overScrollTop > 0) {
                        fingerMove(overScrollTop);
                        mHeaderView.setPercent(mPercent);
                        if (overScrollTop < mMaxDragDistance) {
                            if (!mHeaderView.isAnimationRunning())
                                mHeaderView.startAnimation();
                                //子view进行移动
                            mChildView.setTranslationY(overScrollTop);
                        }
                    } else {
                        return false;
                    }
                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                mIsBeingDragged = false;
                //根据不同的滑动程度来确定不同的手指离开后的效果
                if (mPercent == 1) {
                    mPullEnd = true;
                    mIsRefreshing = true;
                    mHeaderView.setEndpull(true);
                    mHeaderView.setAnimationCurrentPlayTime(400);
                } else {//当没有拉到底部时,将控件返回
                    mPullEnd = false;
                    mIsRefreshing = false;
                    mHeaderView.setEndpull(false);
                    mBackUpValueAnimator.setCurrentPlayTime((long) ((1 - mPercent) * BACK_UP_DURATION));
                    mBackUpValueAnimator.start();

                }
                break;
        }
        return true;
    }

实现起来其实没有那么难, 不过也能从中学习到不少的东西.
源代码地址在我的https://github.com/Mran/HaloRingPullRefresh,欢迎start和issue.
水平不足,说错的地方请多多指教.