android 自定义View:仿QQ拖拽效果

Posted 史大拿

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了android 自定义View:仿QQ拖拽效果相关的知识,希望对你有一定的参考价值。

本系列自定义View全部采用kt

系统:mac

android studio: 4.1.3

kotlin version:1.5.0

gradle: gradle-6.5-bin.zip

废话不多说,先来看今天要完成的效果:

效果一效果二

效果二是在效果一的基础上改的,可以通过一行代码,让所有控件都能实现拖拽效果!

所以先来编写效果一的代码~

基础绘制

首先编写一下基础代码:

class TempView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) 

    companion object 
        // 大圆半径
        private val BIG_RADIUS = 50.dp

        // 小圆半径
        private val SMALL_RADIUS = BIG_RADIUS * 0.618f

        // 最大范围(半径),超出这个范围大圆不显示
        private val MAX_RADIUS = 150.dp
    

    private val paint = Paint().apply 
        color = Color.RED
    

    // 大圆初始位置
    private val bigPointF by lazy  PointF(width / 2f + 300, height / 2f) 

    // 小圆初始位置
    private val smallPointF by lazy  PointF(width / 2f, height / 2f) 

    override fun onDraw(canvas: Canvas) 
        super.onDraw(canvas)

        paint.color = Color.RED
        // 绘制大圆
        canvas.drawCircle(bigPointF.x, bigPointF.y, BIG_RADIUS, paint)

        // 绘制小圆
        canvas.drawCircle(smallPointF.x, smallPointF.y, SMALL_RADIUS, paint)

        // 绘制辅助圆
        paint.color = Color.argb(20, 255, 0, 0)
        canvas.drawCircle(smallPointF.x, smallPointF.y, MAX_RADIUS, paint)
    

现在效果:

这段代码很简单,都是一些基础api的调用,

辅助圆的作用:

  • 当大圆超出辅助圆范围的时候,大圆得“爆炸”,

  • 如果大圆未超出辅助圆内的话,大圆得回弹回去~

主要就是起到这样的作用.

大圆动起来

override fun onTouchEvent(event: MotionEvent): Boolean 
    when (event.action) 
        MotionEvent.ACTION_DOWN -> 

        
        MotionEvent.ACTION_MOVE -> 
            bigPointF.x = event.x
            bigPointF.y = event.y
        
        MotionEvent.ACTION_UP -> 

        
    
    invalidate()
    return true // 消费事件

大圆动起来很简单,只需要在ACTION_MOVE中一直刷新移动位置即可

辅助图1.1:

我们想要的效果是手指按下之后,大圆跟着移动,

辅助图1.1后半段可以看出这里有一个小问题, 手指按什么位置小球就移动到什么位置,不是我们想要的效果

那么我们知道所有的事件都是在DOWN中分发出来的,

所以只需要在DOWN事件中判断当前是否点击到大圆即可,

// 标记是否选中了大圆
var isMove = false

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean 
    when (event.action) 
        MotionEvent.ACTION_DOWN -> 
          // 判断当前点击区域是否在大圆范围内
            isMove = bigPointF.contains(PointF(event.x, event.y), BIG_RADIUS)
        
        MotionEvent.ACTION_MOVE -> 
            if (isMove) 
                bigPointF.x = event.x
                bigPointF.y = event.y
            
        
    
    invalidate()
    return true // 消费事件

contains是自己写的一个扩展函数:

// 判断一个点是否在另一个点内
fun PointF.contains(b: PointF, bPadding: Float = 0f): Boolean 
    val isX = this.x <= b.x + bPadding && this.x >= b.x - bPadding

    val isY = this.y <= b.y + bPadding && this.y >= b.y - bPadding
    return isX && isY

辅助图1.2:

大圆超出辅助圆范围就消失

有了PointF.contains() 这个扩展,任务就变得轻松起来了

只需要在绘制的时候判断一下当前位置即可

override fun onDraw(canvas: Canvas) 
    super.onDraw(canvas)
     // 大圆位置是否在辅助圆内
     if(bigPointF.contains(smallPointF, MAX_RADIUS))
        // 绘制大圆
        canvas.drawCircle(bigPointF.x, bigPointF.y, BIG_RADIUS, paint)
     
  	// 绘制小圆
     ...

    // 绘制辅助圆
    ...

辅助图1.3:

大圆越往外,小球越小

要想求出大圆是否越往外,那么就得先计算出当前大圆与小圆的距离

辅助图1.4:

  • dx = 大圆.x - 小圆.x

  • dy = 大圆.y - 小圆.y

通过勾股定理就可以计算出他们之间的距离

// 小圆与大圆之间的距离
private fun distance(): Float 
   val current = bigPointF - smallPointF
   return sqrt(current.x.toDouble().pow(2.0) + (current.y.toDouble().pow(2.0))).toFloat()

bigPointF - smallPointF 采用的是ktx中自带的运算符重载函数

知道大圆和小圆的距离之后,就可以计算出比例

比例 = 距离 / 总长度

// 大圆与小圆之间的距离
val d = distance()

// 总长度
var ratio = d / MAX_RADIUS
// 如果当前比例 > 0.618 那么就让=0.618
if (ratio > 0.618) 
    ratio = 0.618f

为什么要选0.618,

0.618是黄金比例分割点,听说选了0.618绘制出来的东西会很协调?

我一个糙人也看不出来美不美, 可能是看着更专业一点吧.

完整绘制小圆代码:

//小圆半径
private val SMALL_RADIUS = BIG_RADIUS

override fun onDraw(canvas: Canvas) 
    super.onDraw(canvas)

     // 绘制大圆
  	...

    // 两圆之间的距离
    val d = distance()
    var ratio = d / MAX_RADIUS
    if (ratio > 0.618) 
        ratio = 0.618f
    
    // 小圆半径
    val smallRadius = SMALL_RADIUS - SMALL_RADIUS * ratio
    // 绘制小圆
    canvas.drawCircle(smallPointF.x, smallPointF.y, smallRadius, paint)


    // 绘制辅助圆
   ...

辅助图1.5:

绘制贝塞尔曲线

接下来只需要求出这4个点连接起来 , 看起来就像是把他们连接起来了

然后在找到一个控制点, 通过贝塞尔曲线让他稍微弯曲即可

辅助图1.6:

P1

辅助图1.7:

最终就是算出角A的坐标即可

目前已知

  • 角A.x = 小圆.x + BC;
  • 角A.y = 小圆.y - AC ;

Tips: 因为角A的坐标在小圆中心点上面, 在android坐标系中 角A.y = 小圆.y - AC ;

  • 角C = 90度;
  • 角ABD = 90度

角ABC + 角BAC = 90度; 角ABC +角CBD = 90度;

所以角BAC = 角CBD

BC 平行于 FD,那么角BDF = 角CBD = 角A

最终只要求出角BDF就算出了角A

假设现在知道角A, AB的长度 = 小圆的半径

就可以算出:

  • BC = AB * sin(角A)
  • AC = AB * cos(角A)

现在已知BF 和 FD的距离

角BDF = arctan(BF / FD)

那么现在就计算出了角A的角度

  • p1X = 小圆.x + 小圆半径 * sin(角A)

  • p1Y = 小圆.y - 小圆半径 * cos(角A)

P2

辅助图1.8:

现在要求出P2的位置,也就是角E的位置

  • 角E.x = 大圆.x + DG
  • 角E.y = 大圆.y - EG

角BDE = 90度;

角BDF + 角EDG = 90度

那么角E = 角BDF

P1刚刚计算了角BDF,还是热的.

  • P2.x =大圆.x + DE * sin(角E)
  • P2.y = 大圆.y - DE * cos(角E)

P3

辅助图1.9:

P3就是角K的位置

  • 角K.x = 小圆.x - KH
  • 角K.y = 小圆.y - BH

角KBH + 角HBD = 90度

角BDF + 角HBD = 90度

所以角KBH + 角BDF

KH = BK * sin(角KBH)

BK = BK * cos(角KBH)

  • P3.x = 小圆.x - KH

  • P3.y = 小圆.y - BH

P4

辅助图1.10:

  • 角A.x = 大圆.x - CD

  • 角A.y = 大圆.y + AC

角A + 角ADC = 90度

角BDF + 角ADC = 90度

所以角A = 角BDF

CD = AD * sin(角A)

AC = AD * cos(角A)

  • P4.x = 大圆.x - CD
  • p4.y = 大圆.y - AC

控制点

控制点就选大圆与小圆的中点即可

控制点.x = (大圆.x - 小圆.x) / 2 + 小圆.x

控制点.y = (大圆.y - 小圆.y) / 2 + 小圆.y

来看看完整代码:

/*
* 作者:史大拿
* @param smallRadius: 小圆半径
* @param bigRadius: 大圆半径
*/
 private fun drawBezier(canvas: Canvas, smallRadius: Float, bigRadius: Float) 
     val current = bigPointF - smallPointF

     val BF = current.y.toDouble()
     val FD = current.x.toDouble()
     //
     val BDF = atan(BF / FD)

     val p1X = smallPointF.x + smallRadius * sin(BDF)
     val p1Y = smallPointF.y - smallRadius * cos(BDF)

     val p2X = bigPointF.x + bigRadius * sin(BDF)
     val p2Y = bigPointF.y - bigRadius * cos(BDF)

     val p3X = smallPointF.x - smallRadius * sin(BDF)
     val p3Y = smallPointF.y + smallRadius * cos(BDF)

     val p4X = bigPointF.x - bigRadius * sin(BDF)
     val p4Y = bigPointF.y + bigRadius * cos(BDF)

     // 控制点
     val controlPointX = current.x / 2 + smallPointF.x
     val controlPointY = current.y / 2 + smallPointF.y

     val path = Path()
     path.moveTo(p1X.toFloat(), p1Y.toFloat()) // 移动到p1位置
     path.quadTo(controlPointX, controlPointY, p2X.toFloat(), p2Y.toFloat()) // 绘制贝塞尔

     path.lineTo(p4X.toFloat(), p4Y.toFloat()) // 连接到p4
     path.quadTo(controlPointX, controlPointY, p3X.toFloat(), p3Y.toFloat()) // 绘制贝塞尔
     path.close() // 连接到p1
     canvas.drawPath(path, paint)
 

调用:

override fun onDraw(canvas: Canvas) 
    super.onDraw(canvas)

    paint.color = Color.RED

    // 两圆之间的距离
    val d = distance()
    var ratio = d / MAX_RADIUS
    if (ratio > 0.618) 
        ratio = 0.618f
    
    // 小圆半径
    val smallRadius = SMALL_RADIUS - SMALL_RADIUS * ratio
    // 绘制小圆
    canvas.drawCircle(smallPointF.x, smallPointF.y, smallRadius, paint)

    // 大圆位置是否在辅助圆内
    if (bigPointF.contains(smallPointF, MAX_RADIUS)) 
        // 绘制大圆
        canvas.drawCircle(bigPointF.x, bigPointF.y, BIG_RADIUS, paint)

        // 绘制贝塞尔
        drawBezier(canvas,smallRadius, BIG_RADIUS)
    

    // 绘制辅助圆
    ...

辅助图1.11:

可以看出,基本效果已经达到了,但是这个大圆看着很大,总感觉有地方不协调

那是因为这些参数都是我自己随便写的,到时候这些参数UI都会给你,肯定没有这么随意…

可以先吧大圆半径缩小一点再看看效果如何

辅助图1.12:

看着效果其实还可以.

拖动回弹

拖动回弹是指当拖动大圆时候,没有超出辅助圆的范围, 此时大圆还在辅助圆范围内,

那么就需要将大圆回弹到小圆位置上.

那么肯定是松手(ACTION_UP)事件的时候来处理:

private fun bigAnimator(): ValueAnimator 
    return ObjectAnimator.ofObject(this, "bigPointF", PointFEvaluator(),
        PointF(width / 2f, height / 2f)).apply 
        duration = 400
        interpolator = OvershootInterpolator(3f) // 设置回弹迭代器
    

常见插值器:

  • AccelerateDecelerateInterpolator 动画从开始到结束,变化率是先加速后减速的过程。
  • AccelerateInterpolator 动画从开始到结束,变化率是一个加速的过程。
  • AnticipateInterpolator 开始的时候向后,然后向前甩
  • AnticipateOvershootInterpolator 开始的时候向后,然后向前甩一定值后返回最后的值
  • BounceInterpolator 动画结束的时候弹起
  • CycleInterpolator 动画从开始到结束,变化率是循环给定次数的正弦曲线。
  • DecelerateInterpolator 动画从开始到结束,变化率是一个减速的过程。
  • LinearInterpolator 以常量速率改变
  • OvershootInterpolator 结束时候向反方向甩某段距离

插值器参考链接

小插曲:

最开始初始化大圆位置为:

private val bigPointF by lazy  PointF(width / 2f + 300, height / 2f) 

此时通过动画来改变bigPointF肯定是不可取的,因为他是懒加载

所以要修改初始化代码为:

var bigPointF = PointF(0f, 0f)
 set(value) 
   field = value
   invalidate()
 

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) 
    super.onSizeChanged(w, h, oldw, oldh
    bigPointF.x = width / 2f
    bigPointF.y = height / 2f

如果对为什么要在onSizeChanged中调用不明白的建议看一下View生命周期

调用:

override fun onTouchEvent(event: MotionEvent): Boolean 
    when (event.action) 
         ....
        MotionEvent.ACTION_UP -> 
            // 大圆是否在辅助圆范围内
            if (bigPointF.contains(smallPointF, MAX_RADIUS)) 
                // 回弹
                bigAnimator().start()
             else 
                // 爆炸
            
        
    
    invalidate()
    return true // 消费事件

辅助图1.13:

最后当大圆拖动到辅助圆外的时候,在UP位置绘制爆炸效果,

并且当爆炸效果结束时候,吧大圆x,y坐标回到小圆坐标即可!

绘制爆炸效果

爆炸效果其实就是20张图片一直在切换,达到一帧一帧的效果即可

private val explodeImages by lazy 
    val list = arrayListOf<Bitmap>()
    // BIG_RADIUS = 大圆半径
    val width = BIG_RADIUS * 2 * 2
    list.add(getBitMap(R.mipmap.explode_0, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_1, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_2, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_3, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_4, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_5, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_5, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_6, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_7, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_8, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_9, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_10, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_11, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_12, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_13, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_14, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_15, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_16, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_17, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_18, width.toInt()))
    list.add(getBitMap(R.mipmap.explode_19, width.toInt()))
    list


// 爆炸下标
var explodeIndex = -1
    set(value) 
        field = value
        invalidate()
    

// 属性动画修改爆炸下标,最后一帧的时候回到 -1
private val explodeAnimator by lazy 
        ObjectAnimator.ofInt(this, "explodeIndex", 19, -以上是关于android 自定义View:仿QQ拖拽效果的主要内容,如果未能解决你的问题,请参考以下文章

Android高级控件——自定义ListView高仿一个QQ可拖拽列表的实现

Android自定义View实现仿QQ实现运动步数效果

Android-自定义仿QQ列表Item滑动

自定义View之案列篇:仿QQ小红点

Android自定义View—仿雷达扫描效果

iOS 未读消息角标 仿QQ拖拽 简单灵活 支持xib(源码)