玖叶教程网

前端编程开发入门

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

前言

最近学习了一些自定义控件的知识,想着趁热多做些练习来巩固,上周自定义了一个等级进度条,是一个自定义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方法中判断类型,进行绘制:

@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);
 }
 }

这里有两个地方需要注意一下:

  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

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言