本篇文章已受權微信公衆號 guolin_blog (郭霖)獨家發佈
轉載請標明出處:
gold.xitu.io/post/583566…
本文出自:【張旭童的稀土掘金】(gold.xitu.io/user/56de21…)
代碼傳送門:喜歡的話,隨手點個star。多謝
github.com/mcxtzhang/S…javascript
本篇滑動驗證碼的代碼其實上週四就寫好了,結果週末遇上找房子,搬家,累掉了半條命,趕忙寫篇博客恢復恢復元氣。前端
上週一總監讓我研究一波滑動驗證碼,說項目可能會上。我想了一下好像在鬥魚、淘寶都見過,結果下了這兩個app,發現怎麼點也出不來滑動驗證碼。因而,我就去web端鬥魚看了一下,果真,每次登錄都會出現驗證碼。
好吧,那咱們此次的目標就定爲 在 Android端app上,自定義View,仿一個web端滑動驗證碼吧。
(後話,作到後面發現我有點蠢了,我應該直接模仿app端的,不少效果在web端應該很好實現 ,可是在Android端就不那麼好整了。,例如驗證成功的白光掃過動畫,以下圖。在Android上實現起來就不太容易,有些效果仍是不如web端酷炫。)java
咱們的Demo和web端基本上同樣。android
那麼本控件包含不只包含如下功能:git
分解一下驗證碼核心實現思路:github
onSizeChanged()
方法中生成 和 控件寬高相關的屬性值:onDraw()
時,依次繪製:核心工做是以上,但是實現起來仍是有不少坑的,下面一步一步來吧。web
這裏我省略自定義View的幾個基礎步驟:canvas
完整代碼在
github.com/mcxtzhang/S…
能夠下載後對照閱讀,效果更佳。api
首先思考,驗證碼區域包含:微信
咱們用Path存儲驗證碼區域,
因此這一步最重要是生成驗證碼區域的Path。
查看競品(鬥魚web端)以下,
//生成驗證碼Path
private void createCaptchaPath() {
//本來打算隨機生成gap,後來發現 寬度/3 效果比較好,
int gap = mRandom.nextInt(mCaptchaWidth / 2);
gap = mCaptchaWidth / 3;
//隨機生成驗證碼陰影左上角 x y 點,
mCaptchaX = mRandom.nextInt(mWidth - mCaptchaWidth - gap);
mCaptchaY = mRandom.nextInt(mHeight - mCaptchaHeight - gap);
mCaptchaPath.reset();
mCaptchaPath.lineTo(0, 0);
//從左上角開始 繪製一個不規則的陰影
mCaptchaPath.moveTo(mCaptchaX, mCaptchaY);//左上角
mCaptchaPath.lineTo(mCaptchaX + gap, mCaptchaY);
//draw一個隨機凹凸的圓
drawPartCircle(new PointF(mCaptchaX + gap, mCaptchaY),
new PointF(mCaptchaX + gap * 2, mCaptchaY),
mCaptchaPath, mRandom.nextBoolean());
mCaptchaPath.lineTo(mCaptchaX + mCaptchaWidth, mCaptchaY);//右上角
mCaptchaPath.lineTo(mCaptchaX + mCaptchaWidth, mCaptchaY + gap);
//draw一個隨機凹凸的圓
drawPartCircle(new PointF(mCaptchaX + mCaptchaWidth, mCaptchaY + gap),
new PointF(mCaptchaX + mCaptchaWidth, mCaptchaY + gap * 2),
mCaptchaPath, mRandom.nextBoolean());
mCaptchaPath.lineTo(mCaptchaX + mCaptchaWidth, mCaptchaY + mCaptchaHeight);//右下角
mCaptchaPath.lineTo(mCaptchaX + mCaptchaWidth - gap, mCaptchaY + mCaptchaHeight);
//draw一個隨機凹凸的圓
drawPartCircle(new PointF(mCaptchaX + mCaptchaWidth - gap, mCaptchaY + mCaptchaHeight),
new PointF(mCaptchaX + mCaptchaWidth - gap * 2, mCaptchaY + mCaptchaHeight),
mCaptchaPath, mRandom.nextBoolean());
mCaptchaPath.lineTo(mCaptchaX, mCaptchaY + mCaptchaHeight);//左下角
mCaptchaPath.lineTo(mCaptchaX, mCaptchaY + mCaptchaHeight - gap);
//draw一個隨機凹凸的圓
drawPartCircle(new PointF(mCaptchaX, mCaptchaY + mCaptchaHeight - gap),
new PointF(mCaptchaX, mCaptchaY + mCaptchaHeight - gap * 2),
mCaptchaPath, mRandom.nextBoolean());
mCaptchaPath.close();
}複製代碼
關於drawPartCircle()
,它的功能是傳入起點、終點座標,以及須要凹仍是凸,和繪製的Path。它會在Path上繪製一個凹、凸的半圓。
代碼以下:
/** * 傳入起點、終點 座標、凹凸和Path。 * 會自動繪製凹凸的半圓弧 * * @param start 起點座標 * @param end 終點座標 * @param path 半圓會繪製在這個path上 * @param outer 是否凸半圓 */
public static void drawPartCircle(PointF start, PointF end, Path path, boolean outer) {
float c = 0.551915024494f;
//中點
PointF middle = new PointF(start.x + (end.x - start.x) / 2, start.y + (end.y - start.y) / 2);
//半徑
float r1 = (float) Math.sqrt(Math.pow((middle.x - start.x), 2) + Math.pow((middle.y - start.y), 2));
//gap值
float gap1 = r1 * c;
if (start.x == end.x) {
//繪製豎直方向的
//是不是從上到下
boolean topToBottom = end.y - start.y > 0 ? true : false;
//如下是我寫出了全部的計算公式後推的,不要問我過程,只可意會。
int flag;//旋轉系數
if (topToBottom) {
flag = 1;
} else {
flag = -1;
}
if (outer) {
//凸的 兩個半圓
path.cubicTo(start.x + gap1 * flag, start.y,
middle.x + r1 * flag, middle.y - gap1 * flag,
middle.x + r1 * flag, middle.y);
path.cubicTo(middle.x + r1 * flag, middle.y + gap1 * flag,
end.x + gap1 * flag, end.y,
end.x, end.y);
} else {
//凹的 兩個半圓
path.cubicTo(start.x - gap1 * flag, start.y,
middle.x - r1 * flag, middle.y - gap1 * flag,
middle.x - r1 * flag, middle.y);
path.cubicTo(middle.x - r1 * flag, middle.y + gap1 * flag,
end.x - gap1 * flag, end.y,
end.x, end.y);
}
} else {
//繪製水平方向的
//是不是從左到右
boolean leftToRight = end.x - start.x > 0 ? true : false;
//如下是我寫出了全部的計算公式後推的,不要問我過程,只可意會。
int flag;//旋轉系數
if (leftToRight) {
flag = 1;
} else {
flag = -1;
}
if (outer) {
//凸 兩個半圓
path.cubicTo(start.x, start.y - gap1 * flag,
middle.x - gap1 * flag, middle.y - r1 * flag,
middle.x, middle.y - r1 * flag);
path.cubicTo(middle.x + gap1 * flag, middle.y - r1 * flag,
end.x, end.y - gap1 * flag,
end.x, end.y);
} else {
//凹 兩個半圓
path.cubicTo(start.x, start.y + gap1 * flag,
middle.x - gap1 * flag, middle.y + r1 * flag,
middle.x, middle.y + r1 * flag);
path.cubicTo(middle.x + gap1 * flag, middle.y + r1 * flag,
end.x, end.y + gap1 * flag,
end.x, end.y);
}
/* 沒推導以前的公式在這裏 if (start.x < end.x) { if (outer) { //上左半圓 順時針 path.cubicTo(start.x, start.y - gap1, middle.x - gap1, middle.y - r1, middle.x, middle.y - r1); //上右半圓:順時針 path.cubicTo(middle.x + gap1, middle.y - r1, end.x, end.y - gap1, end.x, end.y); } else { //下左半圓 逆時針 path.cubicTo(start.x, start.y + gap1, middle.x - gap1, middle.y + r1, middle.x, middle.y + r1); //下右半圓 逆時針 path.cubicTo(middle.x + gap1, middle.y + r1, end.x, end.y + gap1, end.x, end.y); } } else { if (outer) { //下右半圓 順時針 path.cubicTo(start.x, start.y + gap1, middle.x + gap1, middle.y + r1, middle.x, middle.y + r1); //下左半圓 順時針 path.cubicTo(middle.x - gap1, middle.y + r1, end.x, end.y + gap1, end.x, end.y); } }*/
}
}複製代碼
這裏用的是推導以後的公式,沒推導前的也在註釋裏。
簡單說,先計算出中點和半徑,利用三次貝塞爾曲線繪製一個圓(c和gap1 都是和三次貝塞爾曲線相關)。關於三次貝塞爾曲線就不展開了,網上不少資料,我也是現學的。
這裏關於繪製驗證碼陰影Path,還有一段曲折心路歷程,
繪製出來的效果以下:
心路歷程(能夠不看):
驗證碼Path,猛的一看,彷佛很簡單,不就是一個矩形+上四個邊可能出現的凹凸嘛。
凹凸的話,咱們就是繪製一個半圓好了。
利用Path
的lineTo()
+addCircle()
彷佛能夠很輕鬆的實現?
最開始我是這麼作的,結果發現畫出來的Path是多段的Path,閉合後,沒法造成一個完整陰影區域。更沒法用於下一步驗證碼滑塊bitmap的生成。
好,看來是addCircle()
的鍋,致使了Path被分割成多段。那我用arcTo()
好了,結果發現arcTo
不像addCircle()
那樣能夠設置繪圖的方向,(順時針,逆時針),這當時可把我難住了,由於不能逆時針的話,上、右邊的凹就畫不出來。因此我放棄了,我轉用貝塞爾曲線
繪製這個凹凸。
文章寫到這裏,我忽然發現本身智障了,sweepAngle傳入負值不就能夠逆時針了嗎。如:arcTo(oval, 180, -180);
因此說寫博客是有很大好處的,寫博客時大腦也是高速旋轉,由於生怕寫出錯誤,一是誤導別人,二是丟人。大腦高速運轉說不定就想通了之前想不通的問題。
因而我就腦殘的用sin+二階貝爾賽曲線去繪製這個半圓了,爲何用它們呢?由於當初我繪製波浪滾動的時候用的sin函數+二階貝塞爾模擬波浪,因而我就慣性思惟的也這麼解決了。結果呢?繪製出來的凹凸不夠圓啊,sin函數仍是比不過圓是否是。
因而我就走上了用三節貝塞爾曲線模擬圓的路。
看來我當初寫這一塊代碼的時候,腦子確實不太清醒,不過也有收穫。又複習了一遍Path的幾個函數和貝塞爾曲線。
驗證碼Path生成好了後,我要根據Path去生成驗證碼滑塊。那麼第一步就是要摳圖了。
代碼以下:
//生成滑塊
private void craeteMask() {
mMaskBitmap = getMaskBitmap(((BitmapDrawable) getDrawable()).getBitmap(), mCaptchaPath);
//滑塊陰影
mMaskShadowBitmap = mMaskBitmap.extractAlpha();
//拖動的位移重置
mDragerOffset = 0;
//isDrawMask 繪製失敗閃爍動畫用
isDrawMask = true;
}複製代碼
//摳圖
private Bitmap getMaskBitmap(Bitmap mBitmap, Path mask) {
//以控件寬高 create一塊bitmap
Bitmap tempBitmap = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);
//把建立的bitmap做爲畫板
Canvas mCanvas = new Canvas(tempBitmap);
//有鋸齒 且沒法解決,因此換成XFermode的方法作
//mCanvas.clipPath(mask);
// 抗鋸齒
mCanvas.setDrawFilter(new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG));
//繪製用於遮罩的圓形
mCanvas.drawPath(mask, mMaskPaint);
//設置遮罩模式(圖像混合模式)
mMaskPaint.setXfermode(mPorterDuffXfermode);
//★考慮到scaleType等因素,要用Matrix對Bitmap進行縮放
mCanvas.drawBitmap(mBitmap, getImageMatrix(), mMaskPaint);
mMaskPaint.setXfermode(null);
return tempBitmap;
}複製代碼
其實這裏我也走了一些曲折的路,我先是用canvas.clipPath(path)
摳的圖,結果發現有鋸齒,搜了不少資料也沒搞定。因而我又回到了Xfermode的路上,將其設置爲mPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.SRC_IN);
先繪製dst,即遮罩驗證碼Path,而後再繪製src:Bitmap,取交集便可完成摳圖。
這裏有一些須要注意的地方:
mMaskShadowBitmap = mMaskBitmap.extractAlpha();
這句話是爲了在繪製出的滑塊周圍也繪製一圈陰影,增強立體效果。
仔細看下圖效果,周邊又一圈立體陰影的效果:
onDraw()
方法其實比較簡單,只不過在其中加入了一些布爾類型的flag,都是和動畫相關的:
代碼以下:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//繼承自ImageView,因此Bitmap,ImageView已經幫咱們draw好了。
//我只在上面繪製和驗證碼相關的部分,
//是否處於驗證模式,在驗證成功後 爲false,其他狀況爲true
if (isMatchMode) {
//首先繪製驗證碼陰影
if (mCaptchaPath != null) {
canvas.drawPath(mCaptchaPath, mPaint);
}
//繪製滑塊
// isDrawMask 繪製失敗閃爍動畫用
if (null != mMaskBitmap && null != mMaskShadowBitmap && isDrawMask) {
// 先繪製陰影
canvas.drawBitmap(mMaskShadowBitmap, -mCaptchaX + mDragerOffset, 0, mMaskShadowPaint);
canvas.drawBitmap(mMaskBitmap, -mCaptchaX + mDragerOffset, 0, null);
}
//驗證成功,白光掃過的動畫,這一塊動畫感受不完美,有提升空間
if (isShowSuccessAnim) {
canvas.translate(mSuccessAnimOffset, 0);
canvas.drawPath(mSuccessPath, mSuccessPaint);
}
}
}複製代碼
mPaint以下定義: 因此繪製出陰影也有一些陰影效果。
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setColor(0x77000000);
//mPaint.setStyle(Paint.Style.STROKE);
// 設置畫筆遮罩濾鏡
mPaint.setMaskFilter(new BlurMaskFilter(20, BlurMaskFilter.Blur.SOLID));複製代碼
值得說的就是,配合滑塊滑動,是利用mDragerOffset
,默認是0,滑動時mDragerOffset
增長,滑塊右移,反之亦然。
驗證成功的白光掃過動畫,是利用canvas.translate()
作的,mSuccessPath
和mSuccessPaint
以下:
mSuccessPaint = new Paint();
mSuccessPaint.setShader(new LinearGradient(0, 0, width, 0, new int[]{
0x11ffffff, 0x88ffffff}, null,
Shader.TileMode.MIRROR));
//模仿鬥魚 是一個平行四邊形滾動過去
mSuccessPath = new Path();
mSuccessPath.moveTo(0, 0);
mSuccessPath.rLineTo(width, 0);
mSuccessPath.rLineTo(width / 2, mHeight);
mSuccessPath.rLineTo(-width, 0);
mSuccessPath.close();複製代碼
上一節完成後,咱們的滑動驗證碼View已經能夠正常繪製出來了,如今咱們爲它增長一些方法,讓它能夠聯動滑動、驗證功能和動畫。
上一節也提到,滑動主要是改變mDragerOffset
的值,而後重繪本身->ondraw()
,根據mDragerOffset
偏移滑塊Bitmap的繪製。
/** * 重置驗證碼滑動距離,(通常用於驗證失敗) */
public void resetCaptcha() {
mDragerOffset = 0;
invalidate();
}
/** * 最大可滑動值 * @return */
public int getMaxSwipeValue() {
//return ((BitmapDrawable) getDrawable()).getBitmap().getWidth() - mCaptchaWidth;
//返回控件寬度
return mWidth - mCaptchaWidth;
}
/** * 設置當前滑動值 * @param value */
public void setCurrentSwipeValue(int value) {
mDragerOffset = value;
invalidate();
}複製代碼
校驗的話,須要引入一個回調接口:
public interface OnCaptchaMatchCallback {
void matchSuccess(SwipeCaptchaView swipeCaptchaView);
void matchFailed(SwipeCaptchaView swipeCaptchaView);
}
/** * 驗證碼驗證的回調 */
private OnCaptchaMatchCallback onCaptchaMatchCallback;
public OnCaptchaMatchCallback getOnCaptchaMatchCallback() {
return onCaptchaMatchCallback;
}
/** * 設置驗證碼驗證回調 * * @param onCaptchaMatchCallback * @return */
public SwipeCaptchaView setOnCaptchaMatchCallback(OnCaptchaMatchCallback onCaptchaMatchCallback) {
this.onCaptchaMatchCallback = onCaptchaMatchCallback;
return this;
}複製代碼
/** * 校驗 */
public void matchCaptcha() {
if (null != onCaptchaMatchCallback && isMatchMode) {
//這裏驗證邏輯,是經過比較,拖拽的距離 和 驗證碼起點x座標。 默認3dp之內算是驗證成功。
if (Math.abs(mDragerOffset - mCaptchaX) < mMatchDeviation) {
//成功的動畫
mSuccessAnim.start();
} else {
mFailAnim.start();
}
}
}複製代碼
成功、失敗的回調是在動畫結束時通知的。
動畫裏要用到寬高,因此它是在onSizeChanged()
方法裏被調用的。
//驗證動畫初始化區域
private void createMatchAnim() {
mFailAnim = ValueAnimator.ofFloat(0, 1);
mFailAnim.setDuration(100)
.setRepeatCount(4);
mFailAnim.setRepeatMode(ValueAnimator.REVERSE);
//失敗的時候先閃一閃動畫 鬥魚是 隱藏-顯示 -隱藏 -顯示
mFailAnim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
onCaptchaMatchCallback.matchFailed(SwipeCaptchaView.this);
}
});
mFailAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float animatedValue = (float) animation.getAnimatedValue();
if (animatedValue < 0.5f) {
isDrawMask = false;
} else {
isDrawMask = true;
}
invalidate();
}
});
int width = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 100, getResources().getDisplayMetrics());
mSuccessAnim = ValueAnimator.ofInt(mWidth + width, 0);
mSuccessAnim.setDuration(500);
mSuccessAnim.setInterpolator(new FastOutLinearInInterpolator());
mSuccessAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mSuccessAnimOffset = (int) animation.getAnimatedValue();
invalidate();
}
});
mSuccessAnim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
isShowSuccessAnim = true;
}
@Override
public void onAnimationEnd(Animator animation) {
onCaptchaMatchCallback.matchSuccess(SwipeCaptchaView.this);
isShowSuccessAnim = false;
isMatchMode = false;
}
});
mSuccessPaint = new Paint();
mSuccessPaint.setShader(new LinearGradient(0, 0, width, 0, new int[]{
0x11ffffff, 0x88ffffff}, null,
Shader.TileMode.MIRROR));
//模仿鬥魚 是一個平行四邊形滾動過去
mSuccessPath = new Path();
mSuccessPath.moveTo(0, 0);
mSuccessPath.rLineTo(width, 0);
mSuccessPath.rLineTo(width / 2, mHeight);
mSuccessPath.rLineTo(-width, 0);
mSuccessPath.close();
}複製代碼
代碼很簡單,修改的一些布爾值flag,在onDraw()
方法裏會用到,結合onDraw()
一看便懂。
這一節,咱們聯動SeekBar滑動起來。
xml以下:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
......
>
<com.mcxtzhang.captchalib.SwipeCaptchaView
android:id="@+id/swipeCaptchaView"
android:layout_width="300dp"
android:layout_height="150dp"
android:layout_centerHorizontal="true"
android:scaleType="centerCrop"
android:src="@drawable/pic11"
app:captchaHeight="30dp"
app:captchaWidth="30dp"/>
<SeekBar
android:id="@+id/dragBar"
android:layout_width="320dp"
android:layout_height="60dp"
android:layout_below="@id/swipeCaptchaView"
android:layout_centerHorizontal="true"
android:layout_marginTop="30dp"
android:progressDrawable="@drawable/dragbg"
android:thumb="@drawable/thumb_bg"/>
<Button
android:id="@+id/btnChange"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:text="老闆換碼"/>
</RelativeLayout>複製代碼
UI就是文首那張圖的樣子,
完整Activity代碼:
public class MainActivity extends AppCompatActivity {
SwipeCaptchaView mSwipeCaptchaView;
SeekBar mSeekBar;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mSwipeCaptchaView = (SwipeCaptchaView) findViewById(R.id.swipeCaptchaView);
mSeekBar = (SeekBar) findViewById(R.id.dragBar);
findViewById(R.id.btnChange).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mSwipeCaptchaView.createCaptcha();
mSeekBar.setEnabled(true);
mSeekBar.setProgress(0);
}
});
mSwipeCaptchaView.setOnCaptchaMatchCallback(new SwipeCaptchaView.OnCaptchaMatchCallback() {
@Override
public void matchSuccess(SwipeCaptchaView swipeCaptchaView) {
Toast.makeText(MainActivity.this, "恭喜你啊 驗證成功 能夠搞事情了", Toast.LENGTH_SHORT).show();
mSeekBar.setEnabled(false);
}
@Override
public void matchFailed(SwipeCaptchaView swipeCaptchaView) {
Toast.makeText(MainActivity.this, "你有80%的多是機器人,如今走還來得及", Toast.LENGTH_SHORT).show();
swipeCaptchaView.resetCaptcha();
mSeekBar.setProgress(0);
}
});
mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
mSwipeCaptchaView.setCurrentSwipeValue(progress);
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
//隨便放這裏是由於控件
mSeekBar.setMax(mSwipeCaptchaView.getMaxSwipeValue());
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
Log.d("zxt", "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]");
mSwipeCaptchaView.matchCaptcha();
}
});
//從網絡加載圖片也ok
Glide.with(this)
.load("http://www.investide.cn/data/edata/image/20151201/20151201180507_281.jpg")
.asBitmap()
.into(new SimpleTarget<Bitmap>() {
@Override
public void onResourceReady(Bitmap resource, GlideAnimation<? super Bitmap> glideAnimation) {
mSwipeCaptchaView.setImageBitmap(resource);
mSwipeCaptchaView.createCaptcha();
}
});
}
}複製代碼
代碼傳送門 喜歡的話,隨手點個star。多謝
github.com/mcxtzhang/S…
包含完整Demo和SwipeCaptchaView。
利用一些工具發現web端鬥魚,驗證碼圖片和滑塊圖片都是接口返回的。
推測前端其實只返回後臺:用戶移動的距離或者距離的百分比。
本例徹底由前端實現驗證碼生成、驗證功能,是由於:
1 練習自定義VIew,本身所有實現摳圖 驗證 繪製,感受很酷。
2 我不會作後臺,手動微笑。
核心點:1 不規則圖形Path的生成。2 指定Path對Bitmap摳圖,抗鋸齒。3 適配ImageView的ScaleType。4 成功、失敗的動畫