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仿腾讯手机管家实现桌面悬浮窗小火箭发射的动画效果