教你編寫一個手勢解鎖控制項
前言
最近學習了一些自定義控制項的知識,想著趁熱多做些練習來鞏固,上周自定義了一個等級進度條,是一個自定義View,這周就換一個類型,做一個自定義的ViewGroup。這周自定義ViewGroup的是一個鎖屏控制項,效果如下:
正文
效果分析
仔細分析效果圖發現,鎖屏控制項需要繪製的有三個部分,分別是:
圖案點,圖案點有四種狀態,分別是默認、選中、正確和錯誤
圖案點之間的連線
連線會根據1中點的狀態改變發生顏色上的變化
懸空線段
就是圖案點和懸空點之間的線段
整體思路
- 自定義一個LockScreenView來表示圖案點,LockScreenView有四種狀態
- 自定義一個LockScreenViewGroup,在onMeasure中獲取到寬度以後(根據寬度算圖案點之間的間距),動態地將LockScreenView添加進來
- 在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方法中判斷類型,進行繪製:
@Override
protected 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方法做一些特殊的處理,至於為什麼要做特殊處理,在上一篇博文《等級進度條》中已經提到過了。
@Override
protected 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中賦予了如下屬性:
<declare-styleable name="LockScreenViewGroup">
<attr name="itemCount" format="integer"/>
<attr name="smallRadius" format="dimension"/>
<attr name="bigRadius" format="dimension"/>
<attr name="normalColor" format="color"/>
<attr name="rightColor" format="color"/>
<attr name="wrongColor" format="color"/>
</declare-styleable>
其中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);
}
}
這裡有兩個地方需要注意一下:
- LockScreenView的寬不能用getMeasuredWidth方法來獲取,因為這裡只是把LockScreenView創建了出來,還沒有對它進行測量,故通過getMeasuredWidth方法只能得到0,這裡直接把LockScreenView中大圓的直徑當作它的寬(因為這裡動態添加的時候用了wrap_content, 並且沒有設padding)
- 重寫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
※TensorFlow中的Eager Execution和自動微分
※藉助Jackson的JsonTypeInfo註解實現多態類的解析
TAG:程序員小新人學習 |