欢迎访问移动开发之家(rcyd.net),关注移动开发教程。移动开发之家  移动开发问答|  每日更新
页面位置 : > > > 内容正文

惊艳-腾讯QQ的Tab按钮动画效果完美实现,惊艳tab,于是利用空闲时间实现了这

来源: 开发者 投稿于  被查看 25530 次 评论:22

惊艳-腾讯QQ的Tab按钮动画效果完美实现,惊艳tab,于是利用空闲时间实现了这


最近在用QQ的时候发现了一个有意思的小细节,如图所示:


可以看到Tab按钮都有一个随着用户拖动而转动的特效,一开始被这个效果惊艳到了,QQ还是很细致的,注重细节和用户体验。

于是利用空闲时间实现了这个效果,所有代码均用kotlin实现,项目效果如图所示:


哈哈是不是一模一样呢,完整的实现代码并不长,只有200多行,但是找思路花了一些时间,也遇到过许多弯路,不过最后都还是坚持下来了,实现的思路概括一下:

首先需要两个背景,内背景(笑脸表情图片)和外背景(笑脸轮廓背景图片),通过反编译QQ的包得到了这两个图片资源文件。然后根据view的onTouchListner,分别在DOWN点击的时候触发放大的动画效果(即上图中的选中状态动画),以及在MOVE的时候判断内背景和外背景的运动,都可以算是向着触摸的点偏移,但是内背景的偏移量比外背景图要多(肉眼可以看出来吧..),所以实现的时候只要注意这个点,以及对偏移边缘(轨迹圆)的判断就可以了。

下面介绍一下实现的步骤以及难点:

1.首先是自定义view的布局文件:

<com.ng.ui.view.CentralTractionButton  
       android:id="@+id/ctt_main"  
       android:button="@null"  
       android:layout_width="100dp"  
       android:layout_height="100dp"  
       android:layout_centerHorizontal="true"  
       android:layout_centerVertical="true"  
       android:background="@android:color/transparent"  
       app:normalexternalbackground="@drawable/iv_rb1_bg_normal"  
       app:normalinsidebackground="@drawable/iv_rb1_in_normal"  
       app:selectedexternalbackground="@drawable/iv_rb1_bg_selected"  
       app:selectedinsidebackground="@drawable/iv_rb1_in_selected"  
       app:text="消息"  
       app:textdimension="12sp" />

其中自定义了几个属性,其中分别对应为:


normalexternalbackground - 未选中状态下的外部背景 
normalinsidebackground - 未选中状态下的内部背景 

selectedexternalbackground - 选中状态下的外部背景

selectedinsidebackground-选中状态下的内部背景

2.在自定义的view中对这些属性做初始化,对应代码如下:

constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {  
    val ta = context.obtainStyledAttributes(attrs, R.styleable.ctattrs)  
    text = ta.getString(R.styleable.ctattrs_text)  
    textdimension = ta.getDimension(R.styleable.ctattrs_textdimension, 1f)  
    normalexternalbackground = ta.getResourceId(R.styleable.ctattrs_normalexternalbackground, 0)  
    normalinsidebackground = ta.getResourceId(R.styleable.ctattrs_normalinsidebackground, 0)  
    selectedinsidebackground = ta.getResourceId(R.styleable.ctattrs_selectedinsidebackground, 0)  
    selectedexternalbackground = ta.getResourceId(R.styleable.ctattrs_selectedexternalbackground, 0)  
  
    //打印所有的属性  
    val count = attrs.attributeCount  
    for (i in 0..count - 1) {  
        val attrName = attrs.getAttributeName(i)  
        val attrVal = attrs.getAttributeValue(i)  
        LogUtils.d("attrName = $attrName , attrVal = $attrVal")  
    }  
    ta.recycle()  
    init()  
}

在init方法中,进行图形的Rect初始化:

private fun init() {  
     initPaint()  
     LogUtils.d("-----init-----")  
     //得到组件宽高中的较小值,再/2得到ob的距离  
     if (mHeight > mWidth) mR = mHeight / 2 else mR = mWidth / 2  
     LogUtils.d("ob的距离:" + mR)  
     mr = mR / 2  
  
     // 背景图绘制区域  
     mExternalDestRect = Rect((centerx - mr).toInt(), (centery - mr).toInt(),  
             (centerx + mr).toInt(),  
             (centery + mr).toInt())  
     //初始化: 75 75 225 225  
  
     // 中心图绘制区域  
     mInsideDestRect = Rect((centerx - mr).toInt(), (centery - mr).toInt(),  
             (centerx + mr).toInt(),  
             (centery + mr).toInt())  
  
  
     // 内外的图形  
     externalBD = resources.getDrawable(normalexternalbackground) as BitmapDrawable  
     mExternalSrcRect = Rect(0, 0, externalBD!!.intrinsicWidth, externalBD!!.intrinsicHeight)  
  
     insidelBD = resources.getDrawable(normalinsidebackground) as BitmapDrawable  
     mInsideSrcRect = Rect(0, 0, insidelBD!!.intrinsicWidth, insidelBD!!.intrinsicHeight)
}

3.在onDraw方法中进行对内外背景的绘制:

override fun onDraw(canvas: Canvas) {  
    super.onDraw(canvas)  
    //暂时画个边框表示范围  
    val bianKuanPaint = Paint()  
    bianKuanPaint.isAntiAlias = true  
    bianKuanPaint.strokeWidth = 2f  
    bianKuanPaint.style = Paint.Style.STROKE  
    bianKuanPaint.color = resources.getColor(R.color.black)  
    canvas.drawRect(0f, 0f, this.width.toFloat(), this.height.toFloat(), bianKuanPaint)  
    //绘制默认状态下背景图  
    val externalBM = externalBD!!.bitmap  
    canvas.drawBitmap(externalBM, mExternalSrcRect, mExternalDestRect, bmPaint)  
    //绘制默认状态下中心图  
    val insidelBM = insidelBD!!.bitmap  
    canvas.drawBitmap(insidelBM, mInsideSrcRect, mInsideDestRect, bmPaint)  
}

可以看到在onDraw中并没有做什么事情,只是绘制图形而已。


4.在onTouchEvent中进行判断:

override fun onTouchEvent(event: MotionEvent): Boolean {  
       //相较于视图的XY  
       var mx1 = event.x  
       var my1 = event.y  
       var mx2 = event.x  
       var my2 = event.y  //需要减掉标题栏高度  
       LogUtils.d("---onTouchEvent---")  
       LogUtils.d(" 点击坐标:$mx1 $my1")  
       when (event.action) {  
           MotionEvent.ACTION_DOWN -> {  
               LogUtils.d("ACTION_DOWN")  
               //TODO 弹动一下的动画效果  
               postInvalidate()  
           }  
           MotionEvent.ACTION_MOVE -> {  
               LogUtils.d("ACTION_MOVE:" + scrollX + " " + scrollY)  
               //判断点击位置距离中心的距离  
               var distanceToCenter = getDistanceTwoPoint(mx1, my1, centerx, centery)  
               var mExternalOffesetLimit = mr / 4  
               var mInsideOffesetLimit = mr / 2  
               //如果区域在轨迹圆内则移动  
               if (distanceToCenter > mExternalOffesetLimit) {  
                   //如果点击位置在组件外,则获取点击位置和中心点连线上的一点(该点满足矩形在组件内)为中心作图  
                   // oc/oa = od/ob  
                   var od = mx1 - centerx  
                   var ob = getDistanceTwoPoint(centerx, centery, mx1, my1)  
                   var oc = od / ob * mExternalOffesetLimit  
                   // ca/oa = db/ob  
                   var db = centery - my1  
                   var ac = db / ob * mExternalOffesetLimit  
                   //得到ac和oc判断得出a点的位置  
                   mx1 = centerx + oc  
                   my1 = centery - ac  
  
                   od = mx2 - centerx  
                   ob = getDistanceTwoPoint(centerx, centery, mx2, my2)  
                   oc = od / ob * mInsideOffesetLimit  
                   // ca/oa = db/ob  
                   db = centery - my2  
                   ac = db / ob * mInsideOffesetLimit  
                   //得到ac和oc判断得出a点的位置  
                   mx2 = centerx + oc  
                   my2 = centery - ac  
               } else {  
                   //获得与中点的距离,*2,如图3  
  
                   var ab = my2 - centery  
                   var bo = mx2 - centerx  
                   LogUtils.d("ab:" + ab + "  bo:" + bo)  
                   mx2 = centerx + 2f * bo  
                   my2 = centery + 2f * ab  
                   distanceToCenter = getDistanceTwoPoint(mx1, my1, centerx, centery)  
                   if (distanceToCenter > mExternalOffesetLimit) {  
                       return super.onTouchEvent(event)  
                   }  
               }  
               var left: Int = (mx1 - mr).toInt()  
               var right: Int = (mx1 + mr).toInt()  
               var top: Int = (my1 - mr).toInt()  
               var bottom: Int = (my1 + mr).toInt()  
               //更新背景图绘制区域  
               mExternalDestRect = Rect(left, top, right, bottom)  
               left = (mx2 - mr).toInt()  
               right = (mx2 + mr).toInt()  
               top = (my2 - mr).toInt()  
               bottom = (my2 + mr).toInt()  
               //更新中心图绘制区域  
               mInsideDestRect = Rect(left, top, right, bottom)  
               postInvalidate()  
           }  
           MotionEvent.ACTION_UP -> {  
               LogUtils.d("ACTION_UP")  
               //复原背景图绘制区域  
               mExternalDestRect = Rect((centerx - mr).toInt(), (centery - mr).toInt(),  
                       (centerx + mr).toInt(),  
                       (centery + mr).toInt())  
               //复原中心图绘制区域  
               mInsideDestRect = Rect((centerx - mr).toInt(), (centery - mr).toInt(),  
                       (centerx + mr).toInt(),  
                       (centery + mr).toInt())  
               postInvalidate()  
           }  
       }  
       LogUtils.d("---end---")  
       return super.onTouchEvent(event)  
   }

其中最复杂的就是在onMonve里的判断了,首先会判断点击的位置距离组件中心点的距离distanceToCenter,如果这个距离大于我指定的轨迹半径(这里取的是外背景图的轨迹圆半径的四分之一,这样的话偏移量就很小了,更接近于QQ的效果)。 如果点击位置大于这个距离,则执行下面的代码:

//如果点击位置在组件外,则获取点击位置和中心点连线上的一点(该点满足矩形在组件内)为中心作图  
                   // oc/oa = od/ob  
                   var od = mx1 - centerx  
                   var ob = getDistanceTwoPoint(centerx, centery, mx1, my1)  
                   var oc = od / ob * mExternalOffesetLimit  
                   // ca/oa = db/ob  
                   var db = centery - my1  
                   var ac = db / ob * mExternalOffesetLimit  
                   //得到ac和oc判断得出a点的位置  
                   mx1 = centerx + oc  
                   my1 = centery - ac  
  
                   od = mx2 - centerx  
                   ob = getDistanceTwoPoint(centerx, centery, mx2, my2)  
                   oc = od / ob * mInsideOffesetLimit  
                   // ca/oa = db/ob  
                   db = centery - my2  
                   ac = db / ob * mInsideOffesetLimit  
                   //得到ac和oc判断得出a点的位置  
                   mx2 = centerx + oc  
                   my2 = centery - ac

这段代码要结合下图来看:


可以看到是根据b点(点击的位置),等比计算出a点的位置(即内外轨迹圆的圆心点),并进行内外背景图的绘制。

如果如果点击位置小于distanceToCenter,则执行下面的代码:

//获得与中点的距离,*2,如图3  
                  var ab = my2 - centery  
                  var bo = mx2 - centerx  
                  LogUtils.d("ab:" + ab + "  bo:" + bo)  
                  mx2 = centerx + 2f * bo  
                  my2 = centery + 2f * ab  
                  distanceToCenter = getDistanceTwoPoint(mx1, my1, centerx, centery)  
                  if (distanceToCenter > mExternalOffesetLimit) {  
                      return super.onTouchEvent(event)  
                  }

结合下图:


可以计算出内圆的圆心点的坐标。这里将内圆的横纵坐标偏移量都延长了两倍,以实现内背景图偏移得更快的效果。


最后全部的代码如下:

/** 
 * Created by GG on 2017/11/2. 
 */  
class CentralTractionButton : RadioButton {  
    //四个图片的id  
    private var normalexternalbackground: Int = 0  
    private var normalinsidebackground: Int = 0  
    private var selectedinsidebackground: Int = 0  
    private var selectedexternalbackground: Int = 0  
    //文字  
    private var textdimension: Float = 0f  
    private var text: String = ""  
    //绘制图形的画笔  
    private var bmPaint: Paint? = null  
    //图形偏移距离  
    private var offsetDistanceLimit: Float = 0.toFloat()  
  
    //组件宽高  
    private var mWidth: Float = 0.toFloat()  
    private var mHeight: Float = 0.toFloat()  
    //中心点坐标,相较于屏幕  
    private var centerX: Float = 0.toFloat()  
    private var centerY: Float = 0.toFloat()  
    //中心点坐标,相较于组件内  
    private var centerx: Float = 0.toFloat()  
    private var centery: Float = 0.toFloat()  
  
  
    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)  
  
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {  
        val ta = context.obtainStyledAttributes(attrs, R.styleable.ctattrs)  
        text = ta.getString(R.styleable.ctattrs_text)  
        textdimension = ta.getDimension(R.styleable.ctattrs_textdimension, 1f)  
        normalexternalbackground = ta.getResourceId(R.styleable.ctattrs_normalexternalbackground, 0)  
        normalinsidebackground = ta.getResourceId(R.styleable.ctattrs_normalinsidebackground, 0)  
        selectedinsidebackground = ta.getResourceId(R.styleable.ctattrs_selectedinsidebackground, 0)  
        selectedexternalbackground = ta.getResourceId(R.styleable.ctattrs_selectedexternalbackground, 0)  
  
        //打印所有的属性  
        val count = attrs.attributeCount  
        for (i in 0..count - 1) {  
            val attrName = attrs.getAttributeName(i)  
            val attrVal = attrs.getAttributeValue(i)  
            LogUtils.d("attrName = $attrName , attrVal = $attrVal")  
        }  
        ta.recycle()  
        init()  
    }  
  
    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {  
        super.onLayout(changed, left, top, right, bottom)  
        mWidth = measuredWidth.toFloat()  
        mHeight = measuredHeight.toFloat()  
        LogUtils.d("onLayout: $mWidth $mHeight")  
        //可供位移的距离  
        offsetDistanceLimit = mWidth / 6  
        centerY = ((getBottom() + getTop()) / 2).toFloat()  
        centerX = ((getRight() + getLeft()) / 2).toFloat()  
        centerx = mWidth / 2  
        centery = mHeight / 2  
        LogUtils.d("中心点坐标: $centerX $centerY")  
        init()  
    }  
  
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {  
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)  
    }  
  
    //轨迹圆外径的半径mR = ob  
    var mR: Float = 0.toFloat()  
    //背景图图形的半径 = 长宽(这里类似于直径)/2 = ob/2  
    var mr: Float = 0.toFloat()  
  
    private fun init() {  
        initPaint()  
        LogUtils.d("-----init-----")  
        //得到组件宽高中的较小值,再/2得到ob的距离  
        if (mHeight > mWidth) mR = mHeight / 2 else mR = mWidth / 2  
        LogUtils.d("ob的距离:" + mR)  
        mr = mR / 2  
  
        // 背景图绘制区域  
        mExternalDestRect = Rect((centerx - mr).toInt(), (centery - mr).toInt(),  
                (centerx + mr).toInt(),  
                (centery + mr).toInt())  
        //初始化: 75 75 225 225  
  
        // 中心图绘制区域  
        mInsideDestRect = Rect((centerx - mr).toInt(), (centery - mr).toInt(),  
                (centerx + mr).toInt(),  
                (centery + mr).toInt())  
  
  
        // 内外的图形  
        externalBD = resources.getDrawable(normalexternalbackground) as BitmapDrawable  
        mExternalSrcRect = Rect(0, 0, externalBD!!.intrinsicWidth, externalBD!!.intrinsicHeight)  
  
        insidelBD = resources.getDrawable(normalinsidebackground) as BitmapDrawable  
        mInsideSrcRect = Rect(0, 0, insidelBD!!.intrinsicWidth, insidelBD!!.intrinsicHeight)  
  
        setOnCheckedChangeListener { compoundButton, b ->  
            if (b) {  
                externalBD = resources.getDrawable(selectedexternalbackground) as BitmapDrawable  
                insidelBD = resources.getDrawable(selectedinsidebackground) as BitmapDrawable  
  
                val pvhX = PropertyValuesHolder.ofFloat("scaleX", 0.1f,  
                        1f)  
                val pvhY = PropertyValuesHolder.ofFloat("scaleY", 0.1f,  
                          1f)  
                val objectAnimator = ObjectAnimator.ofPropertyValuesHolder(this, pvhX, pvhY)  
                objectAnimator.duration = 500  
                val overshootInterpolator = OvershootInterpolator(1.2f)  
                objectAnimator.interpolator = overshootInterpolator  
                objectAnimator.start()  
                postInvalidate()  
            } else {  
                externalBD = resources.getDrawable(normalexternalbackground) as BitmapDrawable  
                insidelBD = resources.getDrawable(normalinsidebackground) as BitmapDrawable  
                postInvalidate()  
            }  
        }  
    }  
  
    //初始化画笔  
    private fun initPaint() {  
        //绘制图形的画笔  
        bmPaint = Paint()  
        bmPaint!!.isAntiAlias = true//抗锯齿功能  
        bmPaint!!.style = Paint.Style.FILL//设置填充样式   Style.FILL/Style.FILL_AND_STROKE/Style.STROKE  
    }  
  
    internal var mExternalSrcRect: Rect? = null  
    internal var mExternalDestRect: Rect? = null  
    internal var mInsideSrcRect: Rect? = null  
    internal var mInsideDestRect: Rect? = null  
  
  
    var externalBD: BitmapDrawable? = null  
    var insidelBD: BitmapDrawable? = null  
    override fun onDraw(canvas: Canvas) {  
        super.onDraw(canvas)  
        //暂时画个边框表示范围  
        val bianKuanPaint = Paint()  
        bianKuanPaint.isAntiAlias = true  
        bianKuanPaint.strokeWidth = 2f  
        bianKuanPaint.style = Paint.Style.STROKE  
        bianKuanPaint.color = resources.getColor(R.color.black)  
        canvas.drawRect(0f, 0f, this.width.toFloat(), this.height.toFloat(), bianKuanPaint)  
        //绘制默认状态下背景图  
        val externalBM = externalBD!!.bitmap  
        canvas.drawBitmap(externalBM, mExternalSrcRect, mExternalDestRect, bmPaint)  
        //绘制默认状态下中心图  
        val insidelBM = insidelBD!!.bitmap  
        canvas.drawBitmap(insidelBM, mInsideSrcRect, mInsideDestRect, bmPaint)  
    }  
  
  
    override fun setOnCheckedChangeListener(listener: CompoundButton.OnCheckedChangeListener) {  
        super.setOnCheckedChangeListener(listener)  
    }  
  
  
    override fun onTouchEvent(event: MotionEvent): Boolean {  
        //相较于视图的XY  
        var mx1 = event.x  
        var my1 = event.y  
        var mx2 = event.x  
        var my2 = event.y  //需要减掉标题栏高度  
        LogUtils.d("---onTouchEvent---")  
        LogUtils.d(" 点击坐标:$mx1 $my1")  
        when (event.action) {  
            MotionEvent.ACTION_DOWN -> {  
                LogUtils.d("ACTION_DOWN")  
                //TODO 弹动一下的动画效果  
                postInvalidate()  
            }  
            MotionEvent.ACTION_MOVE -> {  
                LogUtils.d("ACTION_MOVE:" + scrollX + " " + scrollY)  
                //判断点击位置距离中心的距离  
                var distanceToCenter = getDistanceTwoPoint(mx1, my1, centerx, centery)  
                var mExternalOffesetLimit = mr / 4  
                var mInsideOffesetLimit = mr / 2  
                //如果区域在轨迹圆内则移动  
                if (distanceToCenter > mExternalOffesetLimit) {  
                    //如果点击位置在组件外,则获取点击位置和中心点连线上的一点(该点满足矩形在组件内)为中心作图  
                    // oc/oa = od/ob  
                    var od = mx1 - centerx  
                    var ob = getDistanceTwoPoint(centerx, centery, mx1, my1)  
                    var oc = od / ob * mExternalOffesetLimit  
                    // ca/oa = db/ob  
                    var db = centery - my1  
                    var ac = db / ob * mExternalOffesetLimit  
                    //得到ac和oc判断得出a点的位置  
                    mx1 = centerx + oc  
                    my1 = centery - ac  
  
                    od = mx2 - centerx  
                    ob = getDistanceTwoPoint(centerx, centery, mx2, my2)  
                    oc = od / ob * mInsideOffesetLimit  
                    // ca/oa = db/ob  
                    db = centery - my2  
                    ac = db / ob * mInsideOffesetLimit  
                    //得到ac和oc判断得出a点的位置  
                    mx2 = centerx + oc  
                    my2 = centery - ac  
                } else {  
                    //获得与中点的距离,*2,如图3  
                    var ab = my2 - centery  
                    var bo = mx2 - centerx  
                    LogUtils.d("ab:" + ab + "  bo:" + bo)  
                    mx2 = centerx + 2f * bo  
                    my2 = centery + 2f * ab  
                    distanceToCenter = getDistanceTwoPoint(mx1, my1, centerx, centery)  
                    if (distanceToCenter > mExternalOffesetLimit) {  
                        return super.onTouchEvent(event)  
                    }  
                }  
                var left: Int = (mx1 - mr).toInt()  
                var right: Int = (mx1 + mr).toInt()  
                var top: Int = (my1 - mr).toInt()  
                var bottom: Int = (my1 + mr).toInt()  
                //更新背景图绘制区域  
                mExternalDestRect = Rect(left, top, right, bottom)  
                left = (mx2 - mr).toInt()  
                right = (mx2 + mr).toInt()  
                top = (my2 - mr).toInt()  
                bottom = (my2 + mr).toInt()  
                //更新中心图绘制区域  
                mInsideDestRect = Rect(left, top, right, bottom)  
                postInvalidate()  
            }  
            MotionEvent.ACTION_UP -> {  
                LogUtils.d("ACTION_UP")  
                //复原背景图绘制区域  
                mExternalDestRect = Rect((centerx - mr).toInt(), (centery - mr).toInt(),  
                        (centerx + mr).toInt(),  
                        (centery + mr).toInt())  
                //复原中心图绘制区域  
                mInsideDestRect = Rect((centerx - mr).toInt(), (centery - mr).toInt(),  
                        (centerx + mr).toInt(),  
                        (centery + mr).toInt())  
                postInvalidate()  
            }  
        }  
        LogUtils.d("---end---")  
        return super.onTouchEvent(event)  
    }  
  
  
    //得到两点之间的距离  
    fun getDistanceTwoPoint(x1: Float, y1: Float, x2: Float, y2: Float): Float {  
        return Math.sqrt((Math.pow((x1 - x2).toDouble(), 2.toDouble()) +  
                Math.pow((y1 - y2).toDouble(), 2.toDouble()))).toFloat()  
    }  
  
}

Github地址:https://github.com/jiangzhengnan/UI
有什么不懂的可以加我微信问我~~


作者:

化作孤岛的瓜


用户评论