教你編寫一個手勢解鎖控件

前言

最近學習了一些自定義控件的知識,想著趁熱多做些練習來鞏固,上週自定義了一個等級進度條,是一個自定義View,這周就換一個類型,做一個自定義的ViewGroup。這周自定義ViewGroup的是一個鎖屏控件,效果如下:

正文

效果分析

仔細分析效果圖發現,鎖屏控件需要繪製的有三個部分,分別是:

  • 圖案點,圖案點有四種狀態,分別是默認、選中、正確和錯誤
  • 圖案點之間的連線

連線會根據1中點的狀態改變發生顏色上的變化

  • 懸空線段

就是圖案點和懸空點之間的線段

整體思路

  1. 自定義一個LockScreenView來表示圖案點,LockScreenView有四種狀態
  2. 自定義一個LockScreenViewGroup,在onMeasure中獲取到寬度以後(根據寬度算圖案點之間的間距),動態地將LockScreenView添加進來
  3. 在LockScreenViewGroup的onTouchEvent中消耗觸摸事件,根據觸摸點的軌跡來更新LockScreenView、圖案點連線和懸空線段

實現

  • 自定義LockScreenView

由於沒有和這個自定義View比較類似的原生控件,因此自定義的時候直接繼承自View。首先,需要的屬性通過構造函數傳入:

 private int smallRadius; // LockScreenView小圈的半徑 private int bigRadius; // LockScreenView中大圓圈的半徑 private int normalColor; // LockScreenView中默認的顏色 private int rightColor; // LockScreenView中圖形碼正確時的顏色 private int wrongColor; // LockScreenView中圖形碼錯誤時的顏色public LockScreenView(Context context, int normalColor, int smallRadius, int bigRadius, int rightColor, int wrongColor)

View的狀態用一個枚舉類型來表示

enum State { // 四種狀態,分別是正常狀態、選中狀態、結果正確狀態、結果錯誤狀態 STATE_NORMAL, STATE_CHOOSED, STATE_RESULT_RIGHT, STATE_RESULT_WRONG}

View的狀態通過暴露一個方法給LockScreenViewGroup來進行設置。在onDraw方法中判斷類型,進行繪製:

@Overrideprotected void onDraw(Canvas canvas) { switch(mCurrentState) { case STATE_NORMAL: //  break; case STATE_CHOOSED: // break; case STATE_RESULT_RIGHT: // break; case STATE_RESULT_WRONG: // break; }}

這裡在選中時用屬性動畫做了一個放大效果,在下次恢復正常的時候要將大小恢復回去:

private void zoomOut() { ObjectAnimator animatorX = ObjectAnimator.ofFloat(this, "scaleX", 1, 1.2f); animatorX.setDuration(50); ObjectAnimator animatorY = ObjectAnimator.ofFloat(this, "scaleY", 1, 1.2f); animatorY.setDuration(50); AnimatorSet set = new AnimatorSet(); set.playTogether(animatorX, animatorY); set.start(); needZoomIn = true; }private void zoomIn() { ObjectAnimator animatorX = ObjectAnimator.ofFloat(this, "scaleX", 1, 1f); animatorX.setDuration(0); ObjectAnimator animatorY = ObjectAnimator.ofFloat(this, "scaleY", 1, 1f); animatorY.setDuration(0); AnimatorSet set = new AnimatorSet(); set.playTogether(animatorX, animatorY); set.start(); needZoomIn = false; }

在LockScreenViewGroup中,我將LockScreenView的寬高設置為wrap_content,因此需要在onMeasure方法做一些特殊的處理,至於為什麼要做特殊處理,在上一篇博文《等級進度條》中已經提到過了。

@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); if (widthMode == MeasureSpec.AT_MOST) { widthSize = (int) Math.round(bigRadius*2); } if (heightMode == MeasureSpec.AT_MOST) { heightSize = (int) Math.round(bigRadius*2); } setMeasuredDimension(widthSize, heightSize);}
  • 自定義LockScreenViewGroup

為了方便確定子View的位置,LockScreenViewGroup繼承自RelativeLayout。在xml中賦予瞭如下屬性:

      

其中itemCount表示一行有幾個LockScreenView,其它屬性都已經提到過了。在構造函數中解析xml中的自定義屬性:

public LockScreenViewGroup(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // 從xml中獲取自定義屬性 TypedArray array = getContext().obtainStyledAttributes(attrs, R.styleable.LockScreenViewGroup); itemCount = array.getInt(R.styleable.LockScreenViewGroup_itemCount, 3); smallRadius = (int) array.getDimension(R.styleable.LockScreenViewGroup_smallRadius, 20); bigRadius = (int) array.getDimension(R.styleable.LockScreenViewGroup_bigRadius, 2); normalColor = array.getInt(R.styleable.LockScreenViewGroup_normalColor, 0xffffff); rightColor = array.getColor(R.styleable.LockScreenViewGroup_rightColor, 0x00ff00); wrongColor = array.getColor(R.styleable.LockScreenViewGroup_wrongColor, 0x0000ff); array.recycle();

在onMeasure方法中,獲取到LockScreenViewGroup的寬以後,算出LockScreenView之間的間隙,並動態地將LockScreenView添加進來(每個LockScreenView添加進來的時候,設置id作為唯一標識,後面在判斷圖案是否正確時會用到):

// 動態添加LockScreenView if (lockScreenViews == null) { lockScreenViews = new LockScreenView[itemCount * itemCount]; for (int i = 0; i < itemCount * itemCount; i++) { lockScreenViews[i] = new LockScreenView(getContext(), normalColor, smallRadius, bigRadius, rightColor, wrongColor); lockScreenViews[i].setId(i + 1); RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams( RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT ); // 這裡不能通過lockScreenViews[i].getMeasuredWidth()來獲取寬高,因為這時它的寬高還沒有測量出來 int marginWidth = (getMeasuredWidth() - bigRadius * 2 * itemCount) / (itemCount + 1); // 除了第一行以外,其它的View都在在某個LockScreenView的下面 if (i >= itemCount) { params.addRule(BELOW, lockScreenViews[i - itemCount].getId()); } // 除了第一列以外,其它的View都在某個LockScreenView的右邊 if (i % itemCount != 0) { params.addRule(RIGHT_OF, lockScreenViews[i - 1].getId()); } // 為LockScreenView設置margin int left = marginWidth; int top = marginWidth; int bottom = 0; int right = 0; params.setMargins(left, top, right, bottom); lockScreenViews[i].setmCurrentState(LockScreenView.State.STATE_NORMAL); addView(lockScreenViews[i], params); } }

這裡有兩個地方需要注意一下:

  1. LockScreenView的寬不能用getMeasuredWidth方法來獲取,因為這裡只是把LockScreenView創建了出來,還沒有對它進行測量,故通過getMeasuredWidth方法只能得到0,這裡直接把LockScreenView中大圓的直徑當作它的寬(因為這裡動態添加的時候用了wrap_content, 並且沒有設padding)
  2. 重寫onMeasure方法的時候不能把super.onMeasure方法刪掉,因為這裡面會進行子View寬高的測量,刪了子View就畫不出來了

觸摸事件的消耗在onTouchEvent中處理(在這個案例中也可以在dispatchTouchEvent方法中處理,因為子View的狀態由LockScreenViewGroup告訴它了,子View不需要處理觸摸事件)。在onTouchEvent方法中對Down、Move、Up三種不同的觸摸狀態分別做了處理。

首先,在Down狀態時,需要對之前的狀態做一些重置:

private void resetView() { if (mCurrentViews.size() > 0) { mCurrentViews.clear(); } if (!mCurrentPath.isEmpty()) { mCurrentPath.reset(); } // 重置LockScreenView的狀態 for (int i = 0; i < itemCount * itemCount; i++) { lockScreenViews[i].setmCurrentState(LockScreenView.State.STATE_NORMAL); } skyStartX = -1; skyStartY = -1; }

其中,mCurrentViews用來保存當前選中的LockScreenView的id,mCurrentPath用來保存圖像點間線段的路徑,skyStartX、skyStartY分別是懸空線段起始的x和y。

在Move狀態時,判斷是否在LockScreenView區域,如果在某個LockScreenView區域且這個LockScreenView之前沒有被選中,則將這個LockScreenView設置為選中狀態。另外在onMove中還做了圖案點間線段路徑和懸空線段起點和終點(mTempX、mTempY)的更新,懸空線段的起點就是上一個被選中的LockScreenView的中心點。

case MotionEvent.ACTION_MOVE: mPaint.setColor(normalColor); LockScreenView view = findLockScreenView(x, y); if (view != null) { int id = view.getId(); // 當前LockScreenView不在選中列表中時,將其添加到列表中,並設置其狀態為選中 if (!mCurrentViews.contains(id)) { mCurrentViews.add(id); view.setmCurrentState(LockScreenView.State.STATE_CHOOSED); skyStartX = (view.getLeft() + view.getRight()) / 2; skyStartY = (view.getTop() + view.getBottom()) / 2; // path中線段的添加 if (mCurrentViews.size() == 1) { mCurrentPath.moveTo(skyStartX, skyStartY); } else { mCurrentPath.lineTo(skyStartX, skyStartY); } } } // 懸空線段末端的更新 mTempX = x; mTempY = y; break;

在Up狀態時,根據答案的正確與否,對LockScreenView設置不同的狀態,並且對懸空線段起始點進行重置。

case MotionEvent.ACTION_UP: // 根據圖案正確與否,對LockScreenView設置不同的狀態 if (checkAnswer()) { setmCurrentViewsState(LockScreenView.State.STATE_RESULT_RIGHT); mPaint.setColor(rightColor); } else { setmCurrentViewsState(LockScreenView.State.STATE_RESULT_WRONG); mPaint.setColor(wrongColor); } // 抬起手指後對懸空線段的起始點進行重置 skyStartX = -1; skyStartY = -1;

在onTouchEvent方法最後會調用invalidate方法對視圖進行重繪,這時會調用dispatchDraw方法進行子View的繪製。

在dispatchDraw方法中進行圖像點間的線段路徑以及懸空線段的繪製:

@Override protected void dispatchDraw(Canvas canvas) { // 進行子View的繪製 super.dispatchDraw(canvas); // path線段的繪製 if (!mCurrentPath.isEmpty()) { canvas.drawPath(mCurrentPath, mPaint); } // 懸空線段的繪製 if (skyStartX != -1) { canvas.drawLine(skyStartX, skyStartY, mTempX, mTempY, mPaint); } }

這裡要注意,在重寫dispatchDraw方法時,不能把super.dispatchDraw方法刪掉,因為這裡會繪製LockScreenViewGroup的子View(即,LockScreenView們),如果刪了,動態添加的LockScreenView就會顯示不出來(重寫的時候不小心刪了,排查好久才發現是這裡的問題,都是淚orz)

總結

文章到這裡就結束了。最後,奉上源碼地址:

https://github.com/shonnybing/LockScreenView

教你編寫一個手勢解鎖控件


分享到:


相關文章: