教你编写一个手势解锁控件

前言

最近学习了一些自定义控件的知识,想着趁热多做些练习来巩固,上周自定义了一个等级进度条,是一个自定义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

教你编写一个手势解锁控件


分享到:


相關文章: