Android自定义View之滑块验证码

4,035 阅读5分钟

大家好 我是练习时长俩年半的Android练习生 无伤大雅的你呀

啥也不说了 直接反手给上项目地址以及效果图 交流学习的朋友可以直接拖源码

滑块验证码相信各位看官老爷一定使用过 所以决定上手做一个

本文使用 mdnice 排版

分析

SlidePuzzle分成了俩块 上方的Puzzle以及下方的SlideBar

首先把目光投向SlideBar emmmmm 这玩意好像没啥好讲的

再将目光看向Puzzle 发现还是没啥好讲的 滑块以及背景

绘制

SlideBar拖动条

SlideBar的Draw过程

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    mBitmapHeight = height / 5 * 3
    mBitmapWidth = width / 8
    mBitmap = BitmapUtils.getNewBitmap(mBitmap,mBitmapWidth,mBitmapHeight)
    backgroundRect.set(0f, height.toFloat() / 5 * 2, width.toFloat(), height.toFloat() / 5 * 3)
    //绘制拖动条背景
    canvas.drawRoundRect(backgroundRect,PixelUtils.dp2pxF(context,5f),PixelUtils.dp2pxF(context,5f),mPaint)
    if(mDistance >= width - mBitmapWidth ) mDistance = width - mBitmapWidth
    if(mDistance <= 0) mDistance = 0
    //绘制拖动图片
    canvas.drawBitmap(mBitmap, mDistance.toFloat(), ((height - mBitmapHeight) / 2).toFloat(),mPaint)
}

是不是很朴实无华呢 是的

按照View的宽高根据比例得到新的Bitmap

绘制一个具有小圆角的长方形Rect 作为背景

最后绘制一个用来拖动的图片

滑块Puzzle

Puzzle的onDraw过程

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    if(isShowAnim) {
        canvas.save()
        //绘制完整背景Bitmap
        canvas.drawBitmap(mBitmap, 0f,0f,null)
        //绘制成功动画
        canvas.drawRect(rect,mSuccessPaint)
        canvas.restore()
    }else {
        canvas.save()
        //绘制背景Bitmap
        mBackgroundBitmap?.let { canvas.drawBitmap(it,0f,0f,null) }
        canvas.restore()
        canvas.save()
        //绘制滑块
        mPuzzleBitmap?.let { canvas.drawBitmap(it, -(randomX * mWidth) + mProgress * 0.9f * width, 0f, null) }
        canvas.restore()
    }
}

这里设置了一个参数isShowAnim用来控制是否显示验证成功动画

如果需要显示 则显示完整的Bitmap以及绘制闪光特效

如果不需要显示 则绘制带有缺口的背景Bitmap 以及 滑块

首先 需要构建一个随机位置形状的缺口 也就是Path路径

private fun createPath() {
    randomX = RandomUtils.randomFloat(0.5f,0.8f)
    randomY = RandomUtils.randomFloat(0.1f,0.3f)
    val gapArray = randomGap()
    val sideLength = if(0.1f * mWidth >= 0.1f * mHeight) { 0.1f * mWidth }else { 0.1f * mHeight }
    path.moveTo(randomX * mWidth, randomY * mHeight)
    path.lineTo(randomX * mWidth + 0.2f * sideLength, randomY * mHeight)
    if(1 in gapArray) {
        path.arcTo(RectF(randomX * mWidth + 0.2f * sideLength, randomY * mHeight - 0.2f * sideLength,
        randomX * mWidth + 0.6f * sideLength, randomY * mHeight + 0.2f * sideLength),180f,randomDirection())
    }
    path.lineTo(randomX * mWidth + sideLength, randomY * mHeight)
    path.lineTo(randomX * mWidth + sideLength, randomY * mHeight + 0.2f * sideLength)
    if(2 in gapArray) {
        path.arcTo(RectF(randomX * mWidth + 0.8f * sideLength, randomY * mHeight + 0.2f * sideLength,
        randomX * mWidth + 1.2f * sideLength, randomY * mHeight + 0.6f * sideLength),270f,randomDirection())
    }
    path.lineTo(randomX * mWidth + sideLength, randomY * mHeight + sideLength)
    path.lineTo(randomX * mWidth + 0.6f * sideLength, randomY * mHeight + sideLength)
    if(3 in gapArray){
        path.arcTo(RectF(randomX * mWidth + 0.2f * sideLength, randomY * mHeight + 0.8f * sideLength,
        randomX * mWidth + 0.6f * sideLength, randomY * mHeight + 1.2f * sideLength),0f,randomDirection())
    }
    path.lineTo(randomX * mWidth, randomY * mHeight + sideLength)
    path.lineTo(randomX * mWidth,randomY * mHeight + 0.8f * sideLength)
    if (4 in gapArray) {
        path.arcTo(RectF(randomX * mWidth - 0.2f * sideLength, randomY * mHeight + 0.4f * sideLength,
        randomX * mWidth + 0.2f * sideLength, randomY * mHeight + 0.8f * sideLength),90f,randomDirection())
    }
    path.close()
}

在一定区间内随机产生randomX以及randomY 相对于宽高的比例 确定缺口的左上角位置

这里呢 构建了一个正方形的Path slidelength 边长长度 0.1width或者height

gapArray 一个随机长度为2-4的数组 随机产生滑块圆弧的方向、位置以及数量

有了Path之后就可以开始绘制背景以及滑块了

获取背景

private fun getBackgroundBitmap(bitmap: Bitmap) : Bitmap {
    val newBitmap = Bitmap.createBitmap(bitmap.width,bitmap.height,Bitmap.Config.ARGB_8888)
    val canvas = Canvas(newBitmap)
    canvas.save()
    canvas.drawBitmap(bitmap,0f,0f,null)
    canvas.restore()
    canvas.save()
    canvas.drawPath(path,mShadowPaint)
    canvas.drawPath(path,mWhitePaint)
    canvas.restore()
    return newBitmap
}

创建一个View同等宽高的空白Bitmap 绘制传入的bitmap

随后绘制一个阴影区域以及阴影区域的白色描边

获取滑块

private fun getBlurBitmap(bitmap: Bitmap) : Bitmap {
    val targetBitmap = Bitmap.createBitmap(bitmap.width,bitmap.height,Bitmap.Config.ARGB_8888)
    val rs = RenderScript.create(context)
    val blurScript = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
    val allIn = Allocation.createFromBitmap(rs,bitmap)
    val allOut = Allocation.createFromBitmap(rs,targetBitmap)
    blurScript.setRadius(10f)
    blurScript.setInput(allIn)
    blurScript.forEach(allOut)
    allOut.copyTo(targetBitmap)
    rs.destroy()
    return targetBitmap
}

private fun getPuzzleBitmap(bitmap: Bitmap) : Bitmap {
    val newBitmap = Bitmap.createBitmap(bitmap.width,bitmap.height,Bitmap.Config.ARGB_8888)
    val canvas = Canvas(newBitmap)
    canvas.save()
    canvas.drawPath(path,mPuzzlePaint)
    mPuzzlePaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
    canvas.drawBitmap(bitmap,0f,0f,mPuzzlePaint)
    mPuzzlePaint.xfermode = null
    canvas.restore()
    canvas.drawPath(path,mWhitePaint)
    return newBitmap
}

getBlurBitmap中使用RenderScriptbitmap进行模糊处理

模糊处理过后,创建一个空白Bitmap

绘制一下路径 使用混合模式将目标区域的滑块截取下来 最后也加上白色描边

如此 滑块和背景就大功告成啦

拖动验证

滑动监听

有小白可能要问了 怎么滑动 抱歉 我也不会

开了个小玩笑 滑动其实很简单 直接重写SlideBaronTouchEvent 拦截手势消费事件

override fun onTouchEvent(event: MotionEvent): Boolean {
    when(event.action) {
        MotionEvent.ACTION_DOWN -> {
            currentTemp = System.currentTimeMillis()
        }
        MotionEvent.ACTION_MOVE -> {
            mDistance = if(event.x.toInt() < 0) 0 else event.x.toInt()
            _onDrag?.invoke(mDistance.toFloat() / (width - mBitmapWidth),null,false)
            invalidate()
        }
        MotionEvent.ACTION_UP -> {
            userTime = (System.currentTimeMillis() - currentTemp) / 1000f
            _onDrag?.invoke(mDistance.toFloat() / (width - mBitmapWidth),userTime,true)
        }
    }
    return true
}

private var _onDrag : ((Float, Float?, Boolean) -> Unit)? = null

这里添加了监听onDrag用于同步滑动 参数分别为滑动进度 使用时间 是否需要验证

手指按下时 记录当前按下的时间

手指移动时 记录滑动距离以及当前滑动的比例 重绘SlideBar以及Puzzle

手指松开时 计算滑动时间 验证是否成功

验证回调

mSlideBar.setOnDragListener { progress, useTime, verify ->
    //同步滑块位置
    mPuzzle.setProgress(progress)
    //停止滑动时验证
    if(verify) { verify(abs(progress * 0.9f - mPuzzle.getCurRandomX()) < 0.018f,useTime) }
}

private fun verify(isSuccess : Boolean,useTime : Float?) {
    mTipText.text = if(isSuccess && useTime != null) {
        String.format("拼图成功: 耗时%.1f秒,打败了%d%%的用户!",useTime,(99 - ((if (useTime > 1f) useTime - 1f else 0f) / 0.1f)).toInt())
    }else {
        context.resources.getText(R.string.failure_text)
    }
    mTipText.visibility = View.VISIBLE
    ValueAnimator.ofFloat(0.8f,0.72f).apply {
        addUpdateListener { mTipText.translationY = it.animatedValue as Float }
        duration = 1500
        start()
        doOnEnd { mTipText.visibility = View.GONE }
    }
    if(isSuccess) {
        mPuzzle.showSuccessAnim()
        onVerify?.invoke(true)
    } else {
        mSlideBar.reset()
        onVerify?.invoke(false)
    }
}

private var onVerify : ((Boolean) -> Unit)? = null

如果拖动距离比例与滑块缺口比例在0.018f以内 判断验证成功 否则 验证失败

提供了onVerify 对验证结果的回调


验证失败

弹出验证失败的文字以及重置滑块以及拖动条位置

使用ValueAnimator平滑的重置拖动距离 避免过于生硬

fun reset() {
    //重置拖动位置
    distanceAnimator = ValueAnimator.ofInt(mDistance,0).apply {
        duration = 1000
        addUpdateListener {
            mDistance = it.animatedValue as Int
            invalidate()
        }
        start()
    }
    //重置滑块位置
    progressAnimator = ValueAnimator.ofFloat(mDistance.toFloat() / (width - mBitmapWidth),0f).apply {
        duration = 1000
        addUpdateListener { _onDrag?.invoke(it.animatedValue as Float,null,false) }
        start()
    }
}

验证成功

弹出验证成功以及使用时间的文字提示以及显示成功动画

成功动画就是一道闪光 通过LinearGradient来实现

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    rect.set(0,0,w,h)
    linearGradient = LinearGradient(mWidth.toFloat() / 2.toFloat(),0f,0f,mHeight.toFloat(),
        intArrayOf(Color.BLACK,Color.WHITE,Color.BLACK),null,Shader.TileMode.CLAMP)
    mSuccessPaint.shader = linearGradient
    mSuccessPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.LIGHTEN)
    mGradientMatrix.setTranslate(- 2f * mWidth.toFloat(),mHeight.toFloat())
    linearGradient.setLocalMatrix(mGradientMatrix)
}

private fun initAnimator() {
    valueAnimator = ValueAnimator.ofFloat(0f,1f).apply {
        duration = 1000
        addUpdateListener {
            val progress = it.animatedValue as Float
            mTranslateX = 4 * mWidth * progress - 2 * mWidth
            mTranslateY = mHeight * progress
            mGradientMatrix.setTranslate(mTranslateX,mTranslateY)
            linearGradient.setLocalMatrix(mGradientMatrix)
            invalidate()
        }
    }
}

绘制一个View等宽高的Rect 动画的区域

LinearGradient 前俩个参数 动画的倾斜角度

intArrayOf(Color.BLACK,Color.WHITE,Color.BLACK) 动画的颜色范围

ValueAnimatorlinearGradient当前范围依次增加 重绘Puzzle实现闪光划过效果


大功告成啦 哈哈哈哈哈哈哈哈哈哈哈哈

结语

磕磕碰碰 总算是勉强完成了

期间碰到了很多的问题 摸索着写过来 学习了很多

以后会在这个库中慢慢更新更多的自定义View

最后一句话 送给自己以及各位看官朋友 与诸君共勉

路漫漫其修远兮 吾将上下而求索