前言
本文参考辉哥贝塞尔曲线 - QQ消息汽包拖拽,前面我们使用二阶贝塞尔曲线绘制了拖拽圆点效果Android仿QQ消息拖拽效果(一)(二阶贝塞尔曲线使用),这里我们在此基础之上实现仿QQ消息拖拽爆炸效果。
最终效果
实现思路
- 首先,监听需要拖拽view的
onTouchListener
事件,当手指按下的时候,由于也需要支持在状态栏上拖动
,因此需要把拖拽的view添加到windowmanager
上才支持【参考android窗口】,而我们自身activity中的view是不支持拖动的,因此,我们可以监听当手指按下的时候,隐藏需要拖动的view,并新创建一个支持拖拽的view
(上文自定义的BesselView
),同时复制一份原View的Bitmap
在BesselView
中绘制出来,并将BesselView
添加到WindowManager
中即可。 - 当我们监听到手指移动时,对应
ACTION_MOVE
事件,我们不断更新BesselView
中的移动点的坐标进行重绘即可。 - 当我们监听到手指抬起时,对应
ACTION_UP
事件,我们需要根据拖动距离去判断BesselView
是恢复原view的位置,还是原地爆炸消失,当是恢复原View的位置时,我们设置一个回弹属性动画回弹到之前位置后,将activity中的view
显示出来,同时将BesselView
从WindowManager
上移除,当是爆炸时,我们设置一个爆炸效果,同样也将其从WindowManager
上移除即可。
相关源码
- 支持任何View拖拽的辅助类
DragViewHelper
package com.crystal.view.animation
import android.content.Context
import android.graphics.Bitmap
import android.graphics.PixelFormatProto
import android.graphics.PointF
import android.graphics.drawable.AnimationDrawable
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView
import com.crystal.view.R
/**
* 支持任何View拖拽的辅助类
* on 2022/11/10
*/
class DragViewHelper : BesselView.BesselViewListener {
//目标View
private lateinit var targetView: View
//监听view消失回调
private var dragViewDismissListener: DragViewDismissListener? = null
private lateinit var windowManager: WindowManager
private lateinit var windowManagerLayoutParams: WindowManager.LayoutParams
private lateinit var context: Context
private lateinit var besselView: BesselView
//爆炸动画
private lateinit var bombFrame: FrameLayout
private lateinit var bombImage: ImageView
/**
* 绑定View
*/
fun attachView(context: Context, view: View, listener: DragViewDismissListener?) {
this.context = context
this.targetView = view
this.dragViewDismissListener = listener
initWindowManager()
initDragViewBombView()
addTargetViewOnTouchListener()
}
/**
* 初始化WindowManager
*/
private fun initWindowManager() {
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
//设置拖动时windowManager透明同时导航栏不变成黑色
windowManagerLayoutParams = WindowManager.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.TYPE_APPLICATION,
WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN, PixelFormatProto.TRANSPARENT
)
}
private fun initDragViewBombView() {
besselView = BesselView(context)
besselView.addBesselViewListener(this)
bombFrame = FrameLayout(context)
bombImage = ImageView(context)
bombImage.layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
bombFrame.addView(bombImage)
}
//给view设置onTouchListener监听,用于监听手指拖动
private fun addTargetViewOnTouchListener() {
targetView.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
handleActionDown()
}
MotionEvent.ACTION_MOVE -> {
handleActionMove(event)
}
MotionEvent.ACTION_UP -> {
//手指松开时,要处理回弹或者消失
besselView.handleActionUp()
}
}
true
}
}
/**
* 处理按下事件
*/
private fun handleActionDown() {
//由于需要支持导航栏拖动,所以需要添加推动的View在WindowManger上
windowManager.addView(besselView, windowManagerLayoutParams)
//初始化贝塞尔固定点,需要保证固定点圆心位于view的中心位置
val centerTargetLocation = IntArray(2)
//获取的位置为View左上角坐标
targetView.getLocationOnScreen(centerTargetLocation)
//设置拖拽view的固定位置为目标view的中心位置,但高度不包含状态栏高度,这点需要注意
besselView.updateFixPoint(
centerTargetLocation[0] + targetView.width / 2f,
centerTargetLocation[1] + targetView.height / 2f - DragUtils.getStatusBarHeight(
context
)
)
//将自己隐藏,复制一份自己给besselView
besselView.setDragBitmap(getDragBitmap())
targetView.visibility = View.INVISIBLE
}
private fun handleActionMove(event: MotionEvent) {
//手指移动时,改变besselView的坐标
besselView.updateMovePoint(
event.rawX,
event.rawY - DragUtils.getStatusBarHeight(context)
)
}
/**
* 用于监听View拖动消失
*/
interface DragViewDismissListener {
fun onDismiss()
}
/**
* 获取targetView对应的bitmap
*/
private fun getDragBitmap(): Bitmap {
targetView.buildDrawingCache()
return targetView.drawingCache
}
override fun restore() {
//重置为初始状态
windowManager.removeView(besselView)
targetView.visibility = View.VISIBLE
}
override fun dismiss(pointF: PointF) {
//将view消失
windowManager.removeView(besselView)
windowManager.addView(bombFrame, windowManagerLayoutParams)
bombImage.setBackgroundResource(R.drawable.anim_bubble_pop)
//设置爆炸图片位置【左上角显示image】
bombImage.x = pointF.x - bombImage.width / 2
bombImage.y = pointF.y - bombImage.height / 2
val bombAnimation = bombImage.background as AnimationDrawable
bombAnimation.start()
//等爆炸动画执行完毕后,通知activity view已消失
bombImage.postDelayed({
windowManager.removeView(bombFrame)
dragViewDismissListener?.onDismiss()
}, getBombAnimationDuration(bombAnimation))
}
/**
* 获取爆炸帧动画总体时长
*/
private fun getBombAnimationDuration(bombAnimation: AnimationDrawable): Long {
var totalTime = 0L
for (i in 0 until bombAnimation.numberOfFrames) {
totalTime += bombAnimation.getDuration(i)
}
return totalTime
}
}
- 拖拽效果的
BesselView
较上一篇文章中的有所改动:
1.事件处理交给DragViewHelper;
2.按下拖动时,需要复制一份原View的Bitmap;
3.处理手指抬起后逻辑,并回调对应监听;
package com.crystal.view.animation
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.view.animation.OvershootInterpolator
import kotlin.math.sqrt
/**
* 仿qq消息拖拽效果【二阶贝塞尔曲线学习】
* on 2022/11/10
*/
class BesselView : View {
//画笔工具
private val paint = Paint()
//固定点
private var fixPoint: PointF? = null
//跟随手指移动点
private var movePoint: PointF? = null
//固定点半径【当移动点距离远时,会逐渐变小】
private var fixPointRadius = 0f
//固定点半径最小值
private var fixPointMinRadius = 0f
//固定圆半径最大值
private var fixPointMaxRadius = 0f
//移动点半径
private var movePointRadius = 0f
//将拖动的View复制一份进行绘制
private var dragBitmap: Bitmap? = null
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context, attrs, defStyleAttr
) {
paint.color = Color.RED
paint.isDither = true
paint.isAntiAlias = true
fixPointMinRadius = dp2px(2f)
fixPointMaxRadius = dp2px(7f)
movePointRadius = dp2px(8f)
}
/**
* 更新移动点的坐标
*/
fun updateMovePoint(eventX: Float, eventY: Float) {
if (movePoint == null) {
movePoint = PointF()
}
movePoint?.x = eventX
movePoint?.y = eventY
invalidate()
}
/**
* 更新固定点的坐标
*/
fun updateFixPoint(eventX: Float, eventY: Float) {
if (fixPoint == null) {
fixPoint = PointF()
}
fixPoint?.x = eventX
fixPoint?.y = eventY
}
/**
* 设置拖动的View对应的Bitmap
*/
fun setDragBitmap(dragBitmap: Bitmap) {
this.dragBitmap = dragBitmap
}
override fun onDraw(canvas: Canvas) {
if (fixPoint == null || movePoint == null) {
return
}
//绘制移动点
canvas.drawCircle(movePoint!!.x, movePoint!!.y, movePointRadius, paint)
fixPointRadius = (fixPointMaxRadius - getPointCenterDistance() / 24f).toFloat()
//绘制固定点和贝塞尔曲线【当距离过大时,不绘制贝塞尔曲线和固定点】
if (fixPointRadius > fixPointMinRadius) {
canvas.drawCircle(fixPoint!!.x, fixPoint!!.y, fixPointRadius, paint)
drawBesselLine(canvas)
}
if (dragBitmap != null) {
canvas.drawBitmap(
dragBitmap!!,
movePoint!!.x - dragBitmap!!.width / 2f,
movePoint!!.y - dragBitmap!!.height / 2f,
null
)
}
}
/**
* 绘制二阶贝塞尔曲线
*/
private fun drawBesselLine(canvas: Canvas) {
//分别计算角a的sin值和cos值
val sina = (movePoint!!.y - fixPoint!!.y) / getPointCenterDistance()
val cosa = (movePoint!!.x - fixPoint!!.x) / getPointCenterDistance()
//求出p0点坐标
val p0 = PointF(
(fixPoint!!.x + fixPointRadius * sina).toFloat(),
(fixPoint!!.y - fixPointRadius * cosa).toFloat()
)
//求出p2点坐标
val p2 = PointF(
(fixPoint!!.x - fixPointRadius * sina).toFloat(),
(fixPoint!!.y + fixPointRadius * cosa).toFloat()
)
//求出p1点坐标
val p1 = PointF(
(movePoint!!.x + movePointRadius * sina).toFloat(),
(movePoint!!.y - movePointRadius * cosa).toFloat()
)
//求出p3点坐标
val p3 = PointF(
(movePoint!!.x - movePointRadius * sina).toFloat(),
(movePoint!!.y + movePointRadius * cosa).toFloat()
)
//绘制贝塞尔曲线
val path = Path()
path.moveTo(p0.x, p0.y)
path.quadTo(getCircleCenterPoint().x, getCircleCenterPoint().y, p1.x, p1.y)
path.lineTo(p3.x, p3.y)
path.quadTo(getCircleCenterPoint().x, getCircleCenterPoint().y, p2.x, p2.y)
path.close()
canvas.drawPath(path, paint)
}
/**
* dp 转 px
*/
private fun dp2px(dp: Float): Float {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
}
/**
* 计算两点距离
*/
private fun getPointCenterDistance(): Double {
val dx = movePoint!!.x - fixPoint!!.x
val dy = movePoint!!.y - fixPoint!!.y
return sqrt((dx * dx + dy * dy).toDouble())
}
/**
* 计算两个圆心连接中心点坐标 作为二阶贝塞尔曲线的控制点
*/
private fun getCircleCenterPoint(): PointF {
val centerX = (movePoint!!.x + fixPoint!!.x) / 2
val centerY = (movePoint!!.y + fixPoint!!.y) / 2
return PointF(centerX, centerY)
}
fun handleActionUp() {
if (fixPointRadius > fixPointMinRadius) {
//进行回弹动画,移动到fixPoint位置
val valueAnimator = ObjectAnimator.ofFloat(1f)
valueAnimator.duration = 200
//设置差值器,在结束后回弹效果
valueAnimator.interpolator = OvershootInterpolator(3f)
valueAnimator.addUpdateListener {
val percent = it.animatedValue as Float
//回弹起始点为移动点坐标,终点为固定点坐标,计算结果为动画变化过程中拖动点
val dragPoint = DragUtils.getDragPointByPercent(movePoint!!, fixPoint!!, percent)
updateMovePoint(dragPoint.x, dragPoint.y)
}
valueAnimator.start()
valueAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
//回弹动画结束后,需要把原有位置的view显示出来
besselViewListener?.restore()
}
})
} else {
//进行爆炸效果
besselViewListener?.dismiss(movePoint!!)
}
}
private var besselViewListener: BesselViewListener? = null
fun addBesselViewListener(besselViewListener: BesselViewListener) {
this.besselViewListener = besselViewListener
}
interface BesselViewListener {
//恢复原有view
fun restore()
//原有view爆炸消失
fun dismiss(pointF: PointF)
}
}
- 拖拽计算工具类
DragUtils
package com.crystal.view.animation
import android.content.Context
import android.graphics.PointF
/**
* 拖拽计算工具类
* on 2022/11/10
*/
object DragUtils {
/**
* 用于计算拖拽时位置
*/
fun getDragPointByPercent(startPointF: PointF, endPointF: PointF, percent: Float): PointF {
val x = (endPointF.x - startPointF.x) * percent + startPointF.x
val y = (endPointF.y - startPointF.y) * percent + startPointF.y
return PointF(x, y)
}
/**
* 获取状态栏高度
*/
fun getStatusBarHeight(context: Context): Int {
val resourceId = context.resources.getIdentifier("status_bar_height", "dimen", "android")
if (resourceId > 0) {
return context.resources.getDimensionPixelSize(resourceId)
}
return 0
}
}
总结
通过实现QQ消息拖拽效果,体会到WindowManager
的重要性,很多效果的实现需要它的配合,探索WindowManager
源码势在必行。
结语
如果以上文章对您有一点点帮助,希望您不要吝啬的点个赞加个关注,您每一次小小的举动都是我坚持写作的不懈动力!ღ( ´・ᴗ・` )