前言

本文参考辉哥贝塞尔曲线 - QQ消息汽包拖拽,前面我们使用二阶贝塞尔曲线绘制了拖拽圆点效果Android仿QQ消息拖拽效果(一)(二阶贝塞尔曲线使用),这里我们在此基础之上实现仿QQ消息拖拽爆炸效果。

最终效果

android 拖到此处删除 安卓拖拽_kotlin

实现思路

  • 首先,监听需要拖拽view的onTouchListener事件,当手指按下的时候,由于也需要支持在状态栏上拖动,因此需要把拖拽的view添加到windowmanager上才支持【参考android窗口】,而我们自身activity中的view是不支持拖动的,因此,我们可以监听当手指按下的时候,隐藏需要拖动的view,并新创建一个支持拖拽的view(上文自定义的BesselView),同时复制一份原View的BitmapBesselView中绘制出来,并将BesselView添加到WindowManager中即可。
  • 当我们监听到手指移动时,对应ACTION_MOVE事件,我们不断更新BesselView中的移动点的坐标进行重绘即可。
  • 当我们监听到手指抬起时,对应ACTION_UP事件,我们需要根据拖动距离去判断BesselView是恢复原view的位置,还是原地爆炸消失,当是恢复原View的位置时,我们设置一个回弹属性动画回弹到之前位置后,将activity中的view显示出来,同时将BesselViewWindowManager上移除,当是爆炸时,我们设置一个爆炸效果,同样也将其从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源码势在必行。

结语

如果以上文章对您有一点点帮助,希望您不要吝啬的点个赞加个关注,您每一次小小的举动都是我坚持写作的不懈动力!ღ( ´・ᴗ・` )