zoukankan      html  css  js  c++  java
  • Android仿苹果版QQ下拉刷新实现(二) ——贝塞尔曲线开发"鼻涕"下拉粘连效果

    前言

    接着上一期 Android仿苹果版QQ下拉刷新实现(一) ——打造简单平滑的通用下拉刷新控件 的博客开始,同样,在开始前我们先来看一下目标效果:

    下面上一下本章需要实现的效果图:

    大家看到这个效果肯定不会觉得陌生,QQ已经把粘滞效果做的满大街都是,相信不少读者或多或少对于贝塞尔曲线有所了解,不了解的朋友们也没有关系,在这里我会带领读者领略一下贝塞尔的魅力!

    一、关于贝塞尔曲线

    我们知道,任何一条线段是由起始点和终止点的连线组成,两点组成一条直线,这就是最简单的一阶公式(就是线段):

    一阶贝塞尔曲线表达公式(图略):

    B(t) = P0 + ( P1 - P0 ) t = ( 1 - t ) P0 + t P1 , t∈[0,1]

    很显然,一阶的贝塞尔只是用于一条线段,其中t的变化率代表着线性插值大小.所以我们的效果用于一阶贝塞尔曲线公式肯定不行,下面我们来着重介绍一下二阶(次)贝塞尔曲线变化率和公式:

    (图片来自于网络)

    公式:

    B(t) = ( 1 - t )² P0 + 2 t ( 1 - t ) P1 + t² P2 , t∈[0,1]

    其实公式对于我们的开发者来说并没有太大的意义,因为主要的算法我们的API都已经包含,不过我们需要了解的是,我们的辅助点的查找.首先,我们需要了解曲线是如何画出来的?从图中我们可以看出我们的辅助点是p1点,由p0和p1组成的线段加上p1和p2组成的线段一共是有两条线段,我们需要一个变化率t,t从p0走到p1和从p1走到p2的时间是一样的,这样我们连接两点,就产生了第三条直线(图中绿色的线),这条直线其实就是我们的贝塞尔曲线的切线,只要有了这条直线,我们就可以确定我们的贝塞尔曲线轨迹(这一点至关重要).

    当然,有一阶二阶,肯定也会有三阶、四阶等等.因为辅助点的增加,曲线也会发生各种变化,在这里,博主就不介绍了,想了解更深入的读者,可以在很多关于贝塞尔的博客中去了解.

    介绍完了贝塞尔曲线,接下来我们就要开始着手打造QQ的粘滞效果了.在开始编写代码前我们先分析一下,我们要实现这个效果所需要的准备工作:

    • 自定义View先绘制两个同样大小并重叠的圆形
    • 按照小圆的大小我们设置圆形上刷新图标
    • 重写触摸事件,绘制我们的贝塞尔曲线
    • 动画收回

     

    二、自定义View绘制圆形

     
    在这里,博主选择了自定义view而不是ViewGroup,可能会有人觉得,我们的刷新图标放在ViewGroup中会不会更方便,可以是可以,但是View本身也有绘制图片的功能,所以直接继承View就好.在重写ondraw前,我们先定义好一些变量:
     /**
         * 圆的画笔
         */
        private Paint circlePaint;
        /**
         * 画笔的路径
         */
        private Path circlePath;
    
        /**
         * 可拖动的最远距离
         */
        private int maxHeight;
    
        /**
         * 刷新图标
         */
        private Bitmap bt;
    
        private float topCircleRadius;//默认上面圆形半径
        private float topCircleX;//默认上面圆形x
        private float topCircleY;//默认上面圆形y
    
        private float bottomCircleRadius;//默认上面圆形半径
        private float bottomCircleX;//默认下面圆形x
        private float bottomCircleY;//默认下面圆形y
    
        private float defaultRadius;//默认上面圆形半径
    
        float offset=1.0f;
    
        float lastY;
    
        OnAnimResetListener listener;
    
        ObjectAnimator anim;
    

      

    变量比较多,但是非常好理解,该写的注释也已经标注了,下面我们来看构造函数以及初始化:
     public YPXBezierView(Context context) {
            this(context, null);
        }
    
        public YPXBezierView(Context context, AttributeSet attrs) {
            this(context, attrs, 0);
        }
    
        public YPXBezierView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            init();
        }
    
        protected void init() {
            maxHeight=dp(60);
            topCircleX=ScreenUtils.getScreenWidth(getContext())/2;
            topCircleY=dp(100);
            topCircleRadius=dp(15);
    
            bottomCircleX=topCircleX;
            bottomCircleY=topCircleY;
            bottomCircleRadius=topCircleRadius;
    
            defaultRadius=topCircleRadius;
    
            circlePath = new Path();
    
            circlePaint = new Paint();
            circlePaint.setAntiAlias(true);
            circlePaint.setStyle(Paint.Style.FILL_AND_STROKE);
            circlePaint.setStrokeWidth(1);
            circlePaint.setColor(Color.parseColor("#999999"));
        }
    代码很简单,我们首先定义好我们的一些参数值和初始化画笔,其中maxHeight代表可以拉伸的高度,可以由用户自己去设置,然后就是定位我们的圆形在屏幕上方且居中,最后把底部圆形和顶部圆形重叠.
    初始化好我们的参数,接下来就要看我们的绘制代码了:
     @Override
        protected void onDraw(Canvas canvas) {
            drawPath();
            float left=topCircleX-topCircleRadius;
            float top=topCircleY-topCircleRadius;
    
            canvas.drawPath(circlePath, circlePaint);
            canvas.drawCircle(bottomCircleX, bottomCircleY, bottomCircleRadius, circlePaint);
            canvas.drawCircle(topCircleX, topCircleY, topCircleRadius, circlePaint);
    
            int btWidth=(int) topCircleRadius* 2-dp(6);
            if ((btWidth) > 0) {
                bt = BitmapFactory.decodeResource(getResources(), R.mipmap.refresh);
                bt = Bitmap.createScaledBitmap(bt,btWidth, btWidth, true);
                canvas.drawBitmap(bt, left+dp(3), top+dp(2) , null);
                bt.recycle();
            }
            super.onDraw(canvas);
    
        }
    drawPath是我们绘制贝塞尔的代码,暂且先忽视掉,我们直接从第三行开始,我们要先确定好顶部圆形的左边距离以及顶部距离.为什么要这两个参数呢,因为我们需要根据上圆的位置来定位我们的刷新图标,而自定义View中关于绘制图片的方法最适合本文的莫过于
    public void drawBitmap(Bitmap bitmap, float left, float top, Paint paint)
    这个方法了,画圆形的代码不用多说,直接drawCircle就好,关于刷新图标,我们需要说一下,因为我们的刷新图标是需要跟随大圆的大小变化而变化的,所以它自身的大小一定是可变的,我查阅了关于修改bitmap大小的方法,发现只有在创建的时候使用createScaledBitmap方法,该方法支持bitmap的缩放,但是美中不足的是,它的效果是叠加的,如果把bitmap只创建一次并且不去释放,那么每次刷新的时候会发现我们的刷新图标越来越模糊,目前博主没有什么好的解决方案,只能在绘制的时候重新生成bitmap,如果有了解更优化的方案的话,欢迎大神联系交流~我们的边距是3dp,所以我们的位置需要减去6dp,这样看起来效果更好一点!
     

    三、绘制贝塞尔曲线

     
    关于绘制贝塞尔曲线,安卓系统中有一个专门的方法叫做quadTo,这个是Path的方法,即绘制贝塞尔路径.使用该方法的前提是我们需要找到我们的辅助点,那么我们的重点来了,辅助点怎么找?我们先来看一下博主自己做的一张图解:


    图中有六个重要的点,p1、p2、p3、p4、anchor1、anchor2,因为我们的粘滞小球尽量需要平滑一点,所以博主选择了最简单的四个交叉点(p1~p4),这四个点不涉及到三角函数的处理,所以坐标很容易的就可以得到:
    topCircleX=大圆的X坐标                       bottomCircleX=小圆的X坐标
    topCircleY==大圆的Y坐标                     bottomCircleY==小圆的Y坐标
    topCircleRadius=大圆的半径                 bottomCircleRadius=小圆的半径
    四个点的坐标可以表达为:
    p1 (topCircleX-topCircleRadius , topCircleY)
    p2 (topCircleX+topCircleRadius , topCircleY)
    p3 (bottomCircleX-bottomCircleRadius , bottomCircleY)
    p4 (bottomCircleX+bottomCircleRadius , bottomCircleY)
    那么我们知道了这四个点有什么用呢?
    首先,我们知道左边贝塞尔曲线的初始点(p1)和结束点(p3)以及右边的贝塞尔曲线的初始点(p2)和结束点(p4),我们至少已经确定了两个点,接下来我们去寻找辅助点,回到上图,从图中可以看出,我们的贝塞尔曲线由我们的辅助点anchor1控制,辅助点又是被起点p1和终点p3控制着,因此,当两个圆距离越大,曲线越趋于平缓,当两个圆距离越小,曲线的波动度越大,这样,我们想要的粘连的效果就实现了。所以连接p1和p4,取线段p1p4的中点,我们就可以得左边的辅助点(右边同理),那么我们的两个辅助点坐标:
    anchor1 ((p1x+p4x)/2 , (p1y+p4y)/2)
    anchor1 ((p2x+p3x)/2 , (p2y+p3y)/2)
    知道了原理我们再来看代码就清晰了很多:
     private void drawPath() {
    
            float  p1X = topCircleX - topCircleRadius ;
            float  p1Y = topCircleY ;
            float  p2X = topCircleX + topCircleRadius;
            float  p2Y = topCircleY  ;
            float  p3X = bottomCircleX - bottomCircleRadius ;
            float  p3Y = bottomCircleY ;
            float  p4X = bottomCircleX + bottomCircleRadius ;
            float  p4Y = bottomCircleY ;
    
    
            float anchorX = (p1X+ p4X) / 2-topCircleRadius*offset;
            float anchorY = (p1Y + p4Y) / 2;
    
            float anchorX2 = (p2X +p3X) / 2+topCircleRadius*offset;
            float anchorY2 = (p2Y + p3Y) / 2;
    
            /* 画粘连体 */
            circlePath.reset();
            circlePath.moveTo(p1X, p1Y);
            circlePath.quadTo(anchorX, anchorY, p3X, p3Y);
            circlePath.lineTo(p4X, p4Y);
            circlePath.quadTo(anchorX2, anchorY2, p2X, p2Y);
            circlePath.lineTo(p1X, p1Y);
    
        }
    可能细心的朋友发现,我们的两个辅助点的x坐标动态的加减了 topCircleRadius*offset ,其实这是博主的一个小小的优化,因为按照效果图上的六个点,已经可以画出贝塞尔的粘滞效果,但是我们会发现,描边并不是很圆润,因为我们的曲线是穿过两个圆,所以看起来就和QQ未读消息数的那个气泡效果一样,很显然,和我们的预期刷新效果有一点点不同.在这里我之所以加上这个距离,是想让贝塞尔的起点相对往外切于圆的边上,这样描边出来的效果才更像"鼻涕",为什么要*offset,这个就要涉及到了我们的触摸事件监听了.
     

    三、触摸事件监听以及收回

     
    其实到这里为止,我们就已经可以画出我们想要的效果了,但是如果想要做动态的效果,自然而然就要加入触摸事件,我们先来看一下博主的触摸事件处理代码:
     private void drawPath() {
    
            float  p1X = topCircleX - topCircleRadius ;
            float  p1Y = topCircleY ;
            float  p2X = topCircleX + topCircleRadius;
            float  p2Y = topCircleY  ;
            float  p3X = bottomCircleX - bottomCircleRadius ;
            float  p3Y = bottomCircleY ;
            float  p4X = bottomCircleX + bottomCircleRadius ;
            float  p4Y = bottomCircleY ;
    
    
            float anchorX = (p1X+ p4X) / 2-topCircleRadius*offset;
            float anchorY = (p1Y + p4Y) / 2;
    
            float anchorX2 = (p2X +p3X) / 2+topCircleRadius*offset;
            float anchorY2 = (p2Y + p3Y) / 2;
    
            /* 画粘连体 */
            circlePath.reset();
            circlePath.moveTo(p1X, p1Y);
            circlePath.quadTo(anchorX, anchorY, p3X, p3Y);
            circlePath.lineTo(p4X, p4Y);
            circlePath.quadTo(anchorX2, anchorY2, p2X, p2Y);
            circlePath.lineTo(p1X, p1Y);
    
        }
    主要代码在Move中处理,我们先得到手指滑动的高度,然后判断当前滑动的方向,过滤掉向上的滑动,因为我们的粘滞效果自上而下,所以不需要处理向上的操作(在这里说明一下,如果用户的需求是可以任意方向,就好比QQ的未读消息气泡,那么我们的触摸事件就需要针对手势进行判断,然后在绘制贝塞尔曲线时也要进行方向判断).有了滑动的距离,有了最大滑动距离,那么我们就可以得到滑动的偏移量:
    offset = 1-手指滑动的距离/最大滑动高度  offset∈( 0 ,1 );
    有了offset,我们就可以动态的去设置大圆和小圆的大小及位置,
    小圆的半径 = 初始半径(初始化时大圆的半径)*offset
    小圆的位置向下偏移手指滑动的距离(delayY)
    同时,大圆的半径缩小.这个缩小不是随随便便的缩小的,而是有一个曲线变化,这个曲线变化我们需要改变我们的offset变化率,即:
    offset=(1/3) offset
    这样我们的大圆的半径就会跟随手指一动逐渐缩小,到此,我们的Move事件完整结束.
    介绍完Move事件,我们来看UP,毕竟当我们手指离开控件的时候,我们需要收回,收回很简单,我们只需要把控件置于初始化时状态就好,可是收回的效果很快,几乎是一瞬间,这样的交互并不符合我们一开始的效果,所以,博主决定加入属性动画进行收回:
     public void animToReset(boolean lock){
            if(!lock) {
                Log.e("onAnimationEnd", "动画开始");
                anim= ObjectAnimator.ofFloat(offset, "ypx", 0.0F,  1.0F).setDuration(200);
                //使用反弹算法插值器,貌似没有什么太大的效果 - -!
                anim.setInterpolator(new BounceInterpolator());
                anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                    @Override
                    public void onAnimationUpdate(ValueAnimator animation) {
                        float cVal = (Float) animation.getAnimatedValue();
                        offset = cVal;
                        bottomCircleX=bottomCircleX+(topCircleX-bottomCircleX)*offset;
                        bottomCircleY=bottomCircleY+(topCircleY-bottomCircleY)*offset;
                        bottomCircleRadius=bottomCircleRadius+(topCircleRadius-bottomCircleRadius)*offset;
                        topCircleRadius=topCircleRadius+(defaultRadius-topCircleRadius)*offset;
                        postInvalidate();
                    }
                });
                anim.addListener(new Animator.AnimatorListener() {
                    @Override
                    public void onAnimationStart(Animator animator) {
    
                    }
    
                    @Override
                    public void onAnimationEnd(Animator animator) {
                        Log.e("onAnimationEnd", "动画结束");
                        if (listener != null) {
                            listener.onReset();
                        }
                    }
    
                    @Override
                    public void onAnimationCancel(Animator animator) {
    
                    }
    
                    @Override
                    public void onAnimationRepeat(Animator animator) {
    
                    }
                });
                anim.start();
            }
        }
    忽视掉lock参数,这个参数是为了后面QQ刷新准备的,在此不多介绍,我们直接看onAnimationUpdate动画回调,在这里我们根据返回的每一帧率,动态设置回我们的初始状态并且添加了动画结束的回调,到此,我们的贝塞尔控件全部完成
     

    四、使用和总结

     
    关于使用,肯定是直接在布局中定义即可,不过要注意的是我们的控件并没有添加测量代码,因为滑动的高度有可能是可变的,有可能是不变的,与其让用户去设置,还不如不设置,让其充满它的父控件即可,所以在布局中,宽高设置成match_parent,当然,如果有些极端的情况下,比如父控件的高度要随着我们的小球变化而变化,那么我们就需要在代码中添加onmearsure方法了,让它在wrap_content的时候按照最大距离来测量,在这里,因为博主的效果用不到就没有添加代码,如果有这方面的需求的话,可以联系博主~
    总的来说,本章的效果实现并不是很难,主要在于辅助点的查找,我们可以取一些特殊点,避免复杂的三角函数公式计算,这样不仅我们的性能可以提高,而且也省了很多的代码量,再难的效果都是有一定的原理的,只要花时间弄清楚原理,肯定都能完成.到这里,我们离最后的QQ下拉刷新效果只差一步之摇了,最后一章我会结合以上两篇文章的知识和代码,并且延伸出当前主流的另一种特效,下拉放大效果,有兴趣的还希望读者多多支持哦~
     
     

    感谢大家的支持,谢谢!

    作者:yangpeixing

    QQ:313930500

    下载地址:http://download.csdn.net/detail/qq_16674697/9741375

    转载请注明出处~谢谢~

     
     
     


     
  • 相关阅读:
    Delphi XE5 android 蓝牙通讯传输
    Delphi XE5 android toast
    Delphi XE5 android openurl(转)
    Delphi XE5 如何设计并使用FireMonkeyStyle(转)
    Delphi XE5 android 捕获几个事件
    Delphi XE5 android listview
    Delphi XE5 android 黑屏的临时解决办法
    Delphi XE5 android popumenu
    Delphi XE5 android 获取网络状态
    Delphi XE5 android 获取电池电量
  • 原文地址:https://www.cnblogs.com/teamblog/p/8032316.html
Copyright © 2011-2022 走看看