Android——腾讯QQ的Tab按钮动画效果完美实现

Posted 化作孤岛的瓜

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android——腾讯QQ的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
有什么不懂的可以加我微信问我~~





以上是关于Android——腾讯QQ的Tab按钮动画效果完美实现的主要内容,如果未能解决你的问题,请参考以下文章

仿Android新版手机QQ底部动态按钮效果

android 底部tab 求大神帮写个

Android仿腾讯手机管家实现桌面悬浮窗小火箭发射的动画效果

Android自定义View和属性动画完美结合,创造出酷炫圆环动画,带标尺和进度

Android——点击按钮的阴影动画

Android 高级UI解密 :花式玩转贝塞尔曲线(波浪轨迹变换动画)