查看原文
其他

Android自定义View实现图形验证~

itfitness 技术最TOP 2022-08-26

作者:itfitness 链接:https://www.jianshu.com/p/afd5d9b3fce8

目录

前言

最近看到了一个图形验证的原型感觉挺不错的,顺带再熟练下自定义View于是就用了几个小时写了一个图形验证的控件,在这做个记录,原型如下(是墨刀素材里找的)

实现效果

实现思路

这里就不大量贴代码了就配合部分关键代码简单说下实现思路吧

1.整体构思

整个控件可分为两个部分分别为顶部的圆形图片和底部的滑块,因此我们可以通过分而治之的方法来依次实现,最后再将滑块的滑动与图片的旋转联系起来即可

2.顶部实现

首先顶部的图片展示的位置我选择将其放在了顶部中央位置,然后图片的宽高都为控件宽度的二分之一,然后因为顶部展示的是一个圆形的图片,因此我们需要对图片进行裁切,这里我使用的是混合模式(xfermode),然后由于最后顶部在效果图展示中是可以旋转的,因此我们需要在绘制顶部图的时候调整下canvas的坐标原点为图片的中心点,这样才方便旋转,当然绘制顶部图片的时候不要忘了对canvas进行保存和恢复,关键代码如下:

 canvas.save()
      //先将画布移动到原点为(width/2f,width/4f)的位置(即:显示图片的中心点位置)
      canvas.translate(width/2f,width/4f)
      //根据拖动滑块来调整角度
      canvas.rotate(currentDegrees)
      //利用混合模式将图片画成圆形
      canvas.drawCircle(0f ,0f,(width / 4).toFloat(),paint)
      paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)
      canvas.drawBitmap(bitmap,-bitmap.width / 2f,-bitmap.width / 2f,paint)
      //清空混合模式
      paint.xfermode = null
      canvas.restore()
3.底部实现

底部的实现思路这里我也分为了两部分实现,分别为滑块背景和滑块

  • 1)滑块背景

滑块的背景我是在onSizeChanged方法中通过计算得出一块Path路径,整块背景路径距离顶部图片的距离为控件宽度的1/10,计算的代码如下:

seekTop = width / 10f + bitmap.width

而背景两边的圆弧的半径为控件宽度的1/12:

//滑块两边圆角的半径为控件宽度的十二分之一
circleRadius = width / 12f

然后根据这些值就可以连接出滑块背景的路径了,当然在绘制背景的时候也要注意对描边的处理否则描边绘制出来的话会超出控件,代码如下:

private fun initSeekBgPath() {
//    距离上边图片的高度(为图片的宽度加控件宽度的十分之一)
      val top = seekTop
      //由于画笔的stroke是平均向内向外扩散的,因此需要滑块背景两边需要预留二分之一的seekBgBorderWidth的宽度才能保证不超出控件
      val borderOffset = seekBorderWidth / 2
      //通过计算得出滑块背景的路径
      pathSeekBg.moveTo(circleRadius+borderOffset,top)
      pathSeekBg.addArc(borderOffset,top ,circleRadius * 2 + borderOffset,top + circleRadius * 2,-90f,-180f)
      pathSeekBg.lineTo(width - circleRadius - borderOffset,top + circleRadius * 2)
      pathSeekBg.addArc(width - circleRadius * 2 - borderOffset,top ,width - borderOffset,top + circleRadius * 2,90f,-180f)
      pathSeekBg.lineTo(circleRadius + borderOffset,top)
   }
  • 2)滑块

滑块本质上就是使用canvas绘制的圆圈,而该圆圈的初始中心点也比较好计算,其初始中心点X的值为圆圈的半径,而中心点Y的值为滑块距离控件顶部值加圆圈半径,而用于计算这两个值的seekTopcircleRadius我们在上面的滑块背景那里已经计算出来了,然后这里最主要的就是对手指触摸滑动的处理,我们需要重写onTouchEvent方法,然后MotionEvent.ACTION_DOWN中判断是否手指触摸了滑块,如果触摸了就在MotionEvent.ACTION_MOVE中根据手指触摸的位置动态的改变滑块的位置,而在手指抬起的时候去判断验证是否通过(验证的逻辑我们后面再说),关键代码如下:

override fun onTouchEvent(event: MotionEvent?)Boolean {
      event?.let {
         when(event.action){
            MotionEvent.ACTION_DOWN->{
               //当状态为默认的时候才可以拖动
               if(currentStatus == Status.DEFAULT){
                  //判断触摸点是否在滑块上
                  val rectF = RectF(0f,seekTop,circleRadius * 2,seekTop + circleRadius * 2)
                  if(rectF.contains(event.x,event.y)){
                     isTouch = true
                     postInvalidate()
                  }
               }
            }
            MotionEvent.ACTION_MOVE->{
               if(isTouch){
                  seekMoveX = event.x
                  postInvalidate()
               }
            }
            MotionEvent.ACTION_UP->{
               在这里判断是否验证成功
               isTouch = false
               postInvalidate()
            }
            else -> {}
         }
      }
      return isTouch
   }

当然在改变滑块位置的时候也要注意对边界值的处理,关键代码如下:

seekCenterX = when {
         //处理左边的边界值
          seekMoveX < circleRadius -> {
             circleRadius
          }
         //处理右边的边界值
          seekMoveX > width - circleRadius -> {
             width - circleRadius
          }
          else -> {
             seekMoveX
          }
      }

这里还要注意下滑块上箭头的绘制,由于我没有找到向右的符合要求的箭头符号,因此只好使用向左的箭头符号,然后将其旋转得到向右的箭头,代码如下:

val fontMetrics = paint.fontMetrics
// 计算文字高度ㄍ
val fontHeight = fontMetrics.bottom - fontMetrics.top
// 计算文字baseline 让文字垂直居中
val textBaseY = (circleRadius * 2 - fontHeight) / 2
//由于找不到向右的箭头就用这个符号翻转
canvas.save()
canvas.translate(seekCenterX,centerY)
canvas.rotate(180f)
canvas.drawText("ㄍ",0f,textBaseY,paint)
canvas.restore()
4.底部与顶部的联系

在效果图上展示的是滑块滑动然后图片旋转,而图片旋转的角度,我们是根据滑块横向滑动的距离来计算的这样两部分就联系起来了,其计算公式如下:

图片旋转角度 = 滑块横向移动的距离 / 滑块横向可移动距离 * 360

代码如下:

//根据滑块移动的距离计算旋转的角度
currentDegrees = (seekCenterX - circleRadius) / (width - circleRadius * 2) * 360 + defaultDegrees

当然我们是为了让用户将图片旋转至正确的角度,因此我们一开始必须让图片有个默认的旋转角度,也就是上面代码中的defaultDegrees,而defaultDegrees的值为随机产生的其值范围为-80~-280,代码如下:

//随机初始化默认角度(值的范围为-80~-280),匹配的时候与滑块旋转的角度相加如果在误差范围内就验证成功
      val randomValue = Random().nextInt(201) + 80f
      defaultDegrees = -randomValue
5.结果的验证

在手指抬起的时候我们需要对结果进行验证,而验证的标准就是默认旋转角度与滑块拖动角度的和,如果为0的话就是验证正确,当然用户不可能那么精确,因此我们可以容许一定的误差,这里我默认设置误差offsetDegrees为10,验证的逻辑如下:

if(currentDegrees <= offsetDegrees && currentDegrees >= -offsetDegrees){
                  //验证成功
}else{
                  //验证失败
}
这里我们可以加上一个回调:

 /**
    * 验证的回调
    */

   interface VerifyCallBack{
      fun onSuccess()
      fun onFail()
   }
然后再增加一个验证的状态:

/**
    * 验证的三个状态
    */

   enum class Status{
      DEFAULT,//默认
      FAIL, //失败
      SUCCESS //成功
   }

然后在手指抬起的时候对状态进行修改并调用回调方法

override fun onTouchEvent(event: MotionEvent?)Boolean {
      event?.let {
         when(event.action){
            MotionEvent.ACTION_DOWN->{
                省略部分代码
            }
            MotionEvent.ACTION_MOVE->{
                省略部分代码
            }
            MotionEvent.ACTION_UP->{
               currentStatus = if(currentDegrees <= offsetDegrees && currentDegrees >= -offsetDegrees){
                  //验证成功
                  Status.SUCCESS
               }else{
                  //验证失败
                  Status.FAIL
               }
               verifyCallBack?.let {
                  if(currentStatus == Status.SUCCESS){
                     it.onSuccess()
                  }else{
                     it.onFail()
                  }
               }
               isTouch = false
               postInvalidate()
            }
            else -> {}
         }
      }
      return isTouch
   }
6.控件大小的处理

这里我重新了onMeasure方法,控件的宽度可以随意指定,但是为了确保控件的高度不过大或过小,我将高度与宽度进行了绑定,因此指定高度的值是不好使的,控件的高度始终是刚好能够显示控件,代码如下:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
      val width = measureWidth(widthMeasureSpec)
      setMeasuredDimension(width,measureHeight(width))
   }

   /**
    * 测量宽度
    */

   private fun measureWidth(widthMeasureSpec: Int)Int {
      val mode = MeasureSpec.getMode(widthMeasureSpec)
      var width = MeasureSpec.getSize(widthMeasureSpec)
      if(MeasureSpec.AT_MOST == mode){
         width = 300
      }
      return width
   }
   /**
    * 测量高度(这里的高度只有自适应,如果可以精确指定的话可能会导致控件展示不全),高度根据宽度来定
    */

   private fun measureHeight(width: Int)Int {
      return (width / 10f + width / 2f + width / 6f + seekBorderWidth / 2f).toInt()
   }

使用方法

为了方便使用我在这里将该控件放到了jitpack,使用方法如下:在项目的build.gradle中加入:

allprojects {
        repositories {
            ...
            maven { url 'https://jitpack.io' }
        }
    }

在Module的build.gradle中加入

dependencies {
            implementation 'com.github.itfitness:graphicsverify:v1.4'
    }

布局文件中加入

<com.itfitness.graphicsverify.GraphicsVerifyView
        android:id="@+id/verView"
        android:layout_width="300dp"
        android:layout_height="wrap_content"/>

Activity中加入回调

findViewById<GraphicsVerifyView>(R.id.verView).apply {
            verifyCallBack = object : GraphicsVerifyView.VerifyCallBack{
                override fun onSuccess() {
                    Toast.makeText(this@MainActivity,"成功",Toast.LENGTH_SHORT).show()
                }

                override fun onFail() {
                    Toast.makeText(this@MainActivity,"失败",Toast.LENGTH_SHORT).show()
                }
            }
        }

另外控件的自定义属性如下:

案例源码

https://github.com/itfitness/graphicsverify

---END---


推荐阅读:
Jetpack—LiveData组件的缺陷以及应对策略!
2021年总结,断更大半年,回了重庆,进了大厂!
用更优雅的技术方案实现应用内多弹窗效果!
卷起来了!Android OpenGL 仿自如 APP 裸眼 3D 效果
用了20多张图终于把协程上下文CoroutineContext彻底搞懂了
Android 自定义 仿BiliBili 图片3D切换效果

更文不易,点个“在看”支持一下👇

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存