如下圖。在Android上實現起來就不太容易,有些效果還是不如web端酷炫。)
我們的Demo,Ac娘鎮樓
(圖很渣,也忽略底下的SeekBar,這不是重點)
一些動畫,效果錄不出來了,大家可以去鬥魚web端看一下,然後下載Demo看一下,效果還是可以的。
代碼 傳送門:
https://github.com/mcxtzhang/SwipeCaptcha
我們的Demo和web端基本上一樣。
那麼本控制項包含不僅包含以下功能:
隨機地區起點(左上方x,y)產生一個驗證碼陰影。驗證碼拼圖 凹凸圖形會隨機變換。驗證碼地區寬高可自訂。摳圖驗證碼地區,繪製一個用於聯動滑動的驗證碼滑塊。驗證失敗,會閃爍幾下然後回到原點。驗證成功,會有白光掃過的動畫。
分解一下驗證碼核心實現思路:
控制項繼承自ImageView。理由:
1 如果放在項目中用,驗證碼圖片希望可以是介面返回。ImageView以及其子類支援花式載入圖片。
2 繼承自ImageView,繪製圖片本身不用我們幹預,也不用我們操心scaleType,節省很多工作。在onSizeChanged()
方法中
產生 和 控制項寬高相關的屬性值:
1 初始化時隨機產生驗證碼地區起點
2 產生驗證碼地區Path
3 產生滑塊BitmaponDraw()
時,依次繪製:
1 驗證碼陰影
2 滑塊
核心工作是以上,可是實現起來還是有很多坑的,下面一步一步來吧。
驗證碼地區的產生
這裡我省略自訂View的幾個基礎步驟:
在attrs.xml定義屬性在View的建構函式裡擷取attrs屬性一些Paint,Path的初始化工作
完整代碼在
https://github.com/mcxtzhang/SwipeCaptcha
可以下載後對照閱讀,效果更佳。
首先思考,驗證碼地區包含:
繪製在圖片上的驗證碼陰影
可移動的驗證碼滑塊
1 產生驗證碼陰影
我們用Path儲存驗證碼地區,
所以這一步最重要是產生驗證碼地區的Path。
查看競品(鬥魚web端)如下,
so,我們這裡要繪製一個矩形+四邊可能會有隨機的凹凸,凹凸可以用半圓來替代。
我們如下編寫:
代碼配有注釋,gap是指凹凸的起點和頂點的距離。
//產生驗證碼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的幾個函數和貝茲路徑。
2 摳圖:驗證碼滑塊的產生
驗證碼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,取交集即可完成摳圖。
這裡有一些需要注意的地方:
src的Bitmap是取ImageView本身的bitmap。
建立的新Bitmap的寬高取控制項的寬高
它們兩者的寬高很大可能是不同的,這就是ImageView參數scaleType的作用。所以我們取出ImageView的Matrix 用於繪製src的Bitmap。這樣摳出來的Bitmap地區就和第1步遮蓋住的地區是一樣的了。
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()一看便懂。
Demo
這一節,我們聯動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。多謝
https://github.com/mcxtzhang/SwipeCaptcha
包含完整Demo和SwipeCaptchaView。
利用一些工具發現web端鬥魚,驗證碼圖片和滑塊圖片都是介面返回的。
推測前端其實只返回後台:使用者移動的距離或者距離的百分比。
本例完全由前端實現驗證碼產生、驗證功能,是因為:
1 練習自訂VIew,自己全部實現摳圖 驗證 繪製,感覺很酷。
2 我不會做後台,手動微笑。
核心點:
1 不規則圖形Path的產生。
2 指定Path對Bitmap摳圖,消除鋸齒。
3 適配ImageView的ScaleType。
4 成功、失敗的動畫
以上所述是小編給大家介紹的Android 高仿鬥魚滑動驗證碼,希望對大家有所協助,如果大家有任何疑問請給我留言,小編會及時回複大家的。在此也非常感謝大家對雲棲社區網站的支援!