iOS 圆环滑动验证

81 阅读6分钟

引言

很久很久以前,设计找到我说:“我们要对APP界面交互进行全流程闭环走查重构”,首当其冲的就是登录注册流程,针对注册流程中的图形验证步骤做了优化,本着“好看”的原则,做出了如下优化,上图:

企业微信截图_e492074f-a86e-4293-a366-26ac0b649d6c.png

要求:手指接触中心圆环,手势拖动沿图中虚线拖动到灰色圆环轨道,然后顺时针滑动一周,不足一周恢复初始状态,满足一周业务跳转进行下一步,滑动最多允许一周,不许套圈重复滑动,滑动起始圆环只能沿着虚线方向。OS:之前的不挺好的吗?现实是“美好”的,只能开干。

实现思路

  1. 背景圆环使用CAShapeLayer+UIBezierPath
  2. 中间圆环使用图片+手势
  3. 根据坐标系以及角度限制滑动的有效区域+禁止区域
  4. 涉及虚线绘制+角度和弧度转换

代码实现

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

// 角度 转 弧度
#define kDegreesToRadians(a) \
((a) / 180.0 * M_PI)

// 弧度 转 角度
#define kRadiansToDegrees(r) \
((r) * (180.0 / M_PI))

#define SQR(x)            ( (x) * (x) )

typedef void(^AXCircleBlock)(void);

@interface AXSlideCircleView : UIView

@property (nonatomic, copy) AXCircleBlock circleBlock;

// 线条背景色
@property (nonatomic, strong) UIColor *pathBackColor
// 线条填充色
@property (nonatomic, strong) UIColor *pathFillColor;
// 小圆点图片
@property (nonatomic, strong) UIImageView *pointImage;
// 起点角度 水平右侧为0,顺时针为增加。
@property (nonatomic, assign) CGFloat startAngle;
// 减少的角度 直接传度数 如30
@property (nonatomic, assign) CGFloat reduceAngle;
// 线宽
@property (nonatomic, assign) CGFloat strokeWidth;
// 是否显示小圆点
@property (nonatomic, assign) BOOL showPoint;
// 是否从上次数值开始动画,默认为NO
@property (nonatomic, assign) BOOL increaseFromLast;
// 是否允许重复
@property (nonatomic, assign) BOOL canRepeat;
// 进度 0-1
@property (nonatomic, assign) CGFloat progress;

/**

 初始化
 @param frame 使用自动布局时传CGRectZero
 @param diameter 外圆直径
 @param pathBackColor 背景线条色
 @param pathFillColor 填充线条色
 @param startAngle 开始角度
 @param strokeWidth 线条宽度
 @return SXCircleView
 */

- (instancetype)initWithFrame:(CGRect)frame
                circleDiameter:(CGFloat)diameter
                pathBackColor:(UIColor *)pathBackColor
                pathFillColor:(UIColor *)pathFillColor
                   startAngle:(CGFloat)startAngle
                  strokeWidth:(CGFloat)strokeWidth;
@end

NS_ASSUME_NONNULL_END
#import "AXSlideCircleView.h"

@interface AXSlideCircleView () <UIGestureRecognizerDelegate>

// 背景layer
@property (nonatomic, strong) CAShapeLayer *backLayer;
// 进度layer
@property (nonatomic, strong) CAShapeLayer *progressLayer
// 虚线layer
@property (nonatomic, strong) CAShapeLayer *dashLineLayer;
// 上箭头layer
@property (nonatomic, strong) CAShapeLayer *arrowTopLineLayer;
// 下箭头layer
@property (nonatomic, strong) CAShapeLayer *arrowBotLineLayer;

// 外圆直径
@property (nonatomic, assign) CGFloat circleDiameter;
// 半径
@property (nonatomic, assign) CGFloat radius;
// 禁止顺时针转动
@property (nonatomic, assign) BOOL lockClockwise;
// 禁止逆时针转动
@property (nonatomic, assign) BOOL lockAntiClockwise;
// 滑动是否结束
@property (nonatomic, assign) BOOL isSliderEnd;
// 滑动手势
@property (nonatomic, strong) UIPanGestureRecognizer *sliderRec;
// 中心点
@property CGPoint centerPoint;

@end

@implementation AXSlideCircleView

// MARK: - 初始化
- (instancetype)initWithFrame:(CGRect)frame
                circleDiameter:(CGFloat)diamete
                pathBackColor:(UIColor *)pathBackColor
                pathFillColor:(UIColor *)pathFillColor
                   startAngle:(CGFloat)startAngle
                  strokeWidth:(CGFloat)strokeWidth {
    self = [super initWithFrame:frame];
    if (self) {
        [self initialization:diameter];
        self.pathBackColor = pathBackColor;
        self.pathFillColor = pathFillColor;
        _startAngle = kDegreesToRadians(startAngle);
        _strokeWidth = strokeWidth;
    }
    return self;
}

// MARK: - 初始化数据
- (void)initialization:(CGFloat)diameter {
    self.userInteractionEnabled = YES;
    self.clipsToBounds = YES;
    self.backgroundColor = [UIColor clearColor];
    // 滑动是否结束
    self.isSliderEnd = NO;
    _pathBackColor = AXColor_hex(@"F5F7FB");
    _pathFillColor = AXColor_hex(@"0056FF");
    // 线宽默认为25
    _strokeWidth = 25;
    // 圆起点位置
    _startAngle = kDegreesToRadians(0);
    // 整个圆缺少的角度
    _reduceAngle = kDegreesToRadians(0);
    // 小圆点
    _showPoint = YES;
    // 是否可重复
    _canRepeat = NO;
    _lockAntiClockwise = YES;
    _lockClockwise = NO;
    // 外圆直径
    _circleDiameter = diameter;
    // 初始化layer
    [self initSubviews];
}

// MARK: - 创建视图
- (void)initSubviews {
    [self.layer addSublayer:self.backLayer];
    [self.layer addSublayer:self.progressLayer];
    [self.layer addSublayer:self.dashLineLayer];
    [self.layer addSublayer:self.arrowTopLineLayer];
    [self.layer addSublayer:self.arrowBotLineLayer];
    self.pointImage.userInteractionEnabled = YES;
    [self addSubview:self.pointImage];
    self.sliderRec = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(sliderMoved:)];
    [self.pointImage addGestureRecognizer:self.sliderRec];
}

// MARK: - 更新布局
- (void)layoutSubviews {
    [super layoutSubviews];
    // 半径
    self.radius = _circleDiameter/2.0 - _strokeWidth/2.0;
    // 中心点
    self.centerPoint = CGPointMake(self.frame.size.width/2, self.frame.size.height/2);
    // 背景layer布局
    self.backLayer.frame = CGRectMake((self.frame.size.width-_circleDiameter)/2, (self.frame.size.height-_circleDiameter)/2, _circleDiameter, _circleDiameter);
    self.backLayer.lineWidth = _strokeWidth;
    self.backLayer.path = [self getNewBezierPath].CGPath;

    // 进度layer布局
    self.progressLayer.frame = CGRectMake((self.frame.size.width-_circleDiameter)/2, (self.frame.size.height-_circleDiameter)/2, _circleDiameter, _circleDiameter);
    self.progressLayer.lineWidth = _strokeWidth;
    self.progressLayer.path = [self getNewBezierPath].CGPath;
    self.progressLayer.strokeEnd = 0.0;

    // 虚线layer布局
    CGPoint startPoint = CGPointMake(self.centerPoint.x+sqrt(800), self.centerPoint.y+sqrt(800));
    CGPoint endPoint = [self centerPointForSuperView];
    self.dashLineLayer.lineDashPattern = @[@3,@3];
    self.dashLineLayer.path = [self getDashBezierPath:startPoint endPoint:endPoint].CGPath;

    // 箭头layer布局
    self.arrowTopLineLayer.path = [self getDashBezierPath:endPoint endPoint:CGPointMake(endPoint.x-8, endPoint.y)].CGPath;
    self.arrowBotLineLayer.path = [self getDashBezierPath:endPoint endPoint:CGPointMake(endPoint.x, endPoint.y-8)].CGPath;

    // 滑动图片
    self.pointImage.frame = CGRectMake(0, 0, 80, 80);
    // 更新位置/进度
    [self updatePointPositionAndStroke];
}

// MARK: - 更新点的位置和轨道填充
- (void)updatePointPositionAndStroke {
    if (_isSliderEnd) {
        _pointImage.center = self.centerPoint;
        if (_progress>=0.97) {
            _pointImage.image = [UIImage imageNamed:@"ax_check_success"];
            self.dashLineLayer.hidden = YES;
            self.arrowTopLineLayer.hidden = YES;
            self.arrowBotLineLayer.hidden = YES;
        } else {
            _pointImage.image = [UIImage imageNamed:@"ax_circle_point"];
            self.dashLineLayer.hidden = NO;
            self.arrowTopLineLayer.hidden = NO;
            self.arrowBotLineLayer.hidden = NO;
        }
    } else {
        _pointImage.image = [UIImage imageNamed:@"ax_circle_point"];
        if (_progress>0) {
            _pointImage.center = [self centerPointForSuperView];
            self.dashLineLayer.hidden = YES;
            self.arrowTopLineLayer.hidden = YES;
            self.arrowBotLineLayer.hidden = YES;
        } else {
            _pointImage.center = self.centerPoint;
            self.dashLineLayer.hidden = NO;
            self.arrowTopLineLayer.hidden = NO;
            self.arrowBotLineLayer.hidden = NO;
        }
    }
    [CATransaction begin];
    [CATransaction setDisableActions:YES];
    [CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn]];
    [CATransaction setAnimationDuration:1];
    _progressLayer.strokeEnd = _progress;
    [CATransaction commit];
}

// MARK: - 滑动监听
- (void)sliderMoved:(UIPanGestureRecognizer *)rec {
    if (rec.state == UIGestureRecognizerStateBegan) {
        // 滑动是否结束
        self.isSliderEnd = NO;
    } else if (rec.state == UIGestureRecognizerStateChanged) {
        // 滑动是否结束
        self.isSliderEnd = NO;
        // 获取触摸点
        CGPoint touchPoint = [rec locationInView:self];
        // 计算中心点到任意点的角度
        float currentAngle = AngleFromNorth(self.centerPoint,touchPoint);
        // 计算中心点到任意点的距离
        float distance = DistanceForTwoPoint(self.centerPoint, touchPoint);
        // 有效距离最大/小值
        float effectMaxDis = _circleDiameter/2.0;
        float effectMinDis = _circleDiameter/2.0 - _strokeWidth;
        // 触摸点不在有效范围内直接返回
        if (distance>effectMaxDis+70) {
            return;
        }
        if (self.progress<=0) {
            // 移动滑块的条件
            // 滑动在第二象限并且角度在[0~90]或
            // 滑动在第一象限并且角度在[340~360]
            if ((touchPoint.x>=self.centerPoint.x&&touchPoint.y>self.centerPoint.y&&currentAngle>=0&&currentAngle<=90) || (touchPoint.x>=self.centerPoint.x&&touchPoint.y<self.centerPoint.y&&currentAngle>=340&&currentAngle<=360)) {
                // 防止滑块移动到圆环之外
                if (distance<=effectMaxDis) {
                    self.pointImage.center = touchPoint;
                }
                self.dashLineLayer.hidden = YES;
                self.arrowTopLineLayer.hidden = YES;
                self.arrowBotLineLayer.hidden = YES;
            }
        }
        if (distance<effectMinDis-40) {
            return;
        }

        // 计算滑块中心到任意点的距离
        float pointDistance = DistanceForTwoPoint(self.pointImage.center, touchPoint);
        if (pointDistance>=_circleDiameter/2.0) {
            return;
        }
        // 起始位置角度
        CGFloat startAngle = kRadiansToDegrees(_startAngle);
        float angle = currentAngle;
        if (currentAngle>=startAngle) {
            angle = currentAngle-startAngle;
        } else {
            angle = 360 - startAngle + currentAngle;
        }
        if (!self.canRepeat) {
            // 当前角度大于40&&小于等于45度时,
            // 禁止移动到第一、二(大于起始位置的范围)、三、四象限
            if (self.lockClockwise) {
                if ((touchPoint.x >= self.centerPoint.x && touchPoint.y <= self.centerPoint.y) || (touchPoint.x <= self.centerPoint.x && touchPoint.y >= self.centerPoint.y) ||
                    (touchPoint.x <= self.centerPoint.x && touchPoint.y <= self.centerPoint.y)  ||(
                    (touchPoint.x >= self.centerPoint.x && touchPoint.y >= self.centerPoint.y) && currentAngle>=startAngle)) {
                    return;
                }
            }
            // 当前角度大于等于45&&小于50度时,
            // 禁止移动到第一、二(小于起始位置的范围)、三、四象限
            if (self.lockAntiClockwise) {
                if ((touchPoint.x >= self.centerPoint.x && touchPoint.y <= self.centerPoint.y) || (touchPoint.x <= self.centerPoint.x && touchPoint.y >= self.centerPoint.y) ||
                    (touchPoint.x <= self.centerPoint.x && touchPoint.y <= self.centerPoint.y)  ||(
                    (touchPoint.x >= self.centerPoint.x && touchPoint.y >= self.centerPoint.y) && currentAngle<startAngle)) {
                    return;
                }
            }
        }

        // 当前角度大于30&&小于等于45度时,
        // 禁止移动到第一、二(大于起始位置的范围)、三、四象限
        if (currentAngle>40&&currentAngle <= 45) {
            self.lockClockwise = YES;
        } else {
            self.lockClockwise = NO;
        }

        // 当前角度大于等于45&&小于50度时,
        // 禁止移动到第一、二(小于起始位置的范围)、三、四象限
        if (currentAngle >= 45&&currentAngle < 50) {
            self.lockAntiClockwise = YES;
        } else {
            self.lockAntiClockwise = NO;
        }
        if (self.progress==1) {
            return;
        } else {
            if (angle/360>=0.97) {
                self.progress = 1.0;
            } else {
                self.progress = angle/360;
            }
        }
    } else if (rec.state == UIGestureRecognizerStateEnded || rec.state == UIGestureRecognizerStateCancelled || rec.state == UIGestureRecognizerStateFailed) {
        // 滑动是否结束
        self.isSliderEnd = YES;
        self.lockAntiClockwise = YES;
        self.lockClockwise = NO;
        // 结束位置
        if (self.progress<0.97) {
            self.progress = 0.0;
        } else {
            self.progress = 1.0;
            if (self.circleBlock) {
                self.circleBlock();
            }
            [self performSelector:@selector(resetCircle) withObject:self afterDelay:1.5];
        }
    }
}

// MARK: - 重置圆环
- (void)resetCircle {
    self.progress = 0.0;
}

// MARK: - Get
- (CAShapeLayer *)backLayer {
    if (!_backLayer) {
        _backLayer = [CAShapeLayer layer];
        _backLayer.fillColor = [UIColor clearColor].CGColor;//填充色
        _backLayer.lineWidth = _strokeWidth;
        _backLayer.strokeColor = _pathBackColor.CGColor;
        _backLayer.lineCap = @"round";
    }
    return _backLayer;
}

- (CAShapeLayer *)progressLayer {
    if (!_progressLayer) {
        _progressLayer = [CAShapeLayer layer];
        _progressLayer.fillColor = [UIColor clearColor].CGColor;//填充色
        _progressLayer.lineWidth = _strokeWidth;
        _progressLayer.strokeColor = _pathFillColor.CGColor;
        _progressLayer.lineCap = @"round";
    }
    return _progressLayer;
}

- (CAShapeLayer *)dashLineLayer {
    if (!_dashLineLayer) {
        _dashLineLayer = [CAShapeLayer layer];
        _dashLineLayer.fillColor = nil;
        _dashLineLayer.lineWidth = 0.5;
        _dashLineLayer.strokeColor = AXColor_hex(@"9CA6B9").CGColor;
    }
    return _dashLineLayer;
}

- (CAShapeLayer *)arrowTopLineLayer 
    if (!_arrowTopLineLayer) {
        _arrowTopLineLayer = [CAShapeLayer layer];
        _arrowTopLineLayer.fillColor = nil;
        _arrowTopLineLayer.lineWidth = 1.0;
        _arrowTopLineLayer.strokeColor = AXColor_hex(@"9CA6B9").CGColor;
    }
    return _arrowTopLineLayer;
}

- (CAShapeLayer *)arrowBotLineLayer {
    if (!_arrowBotLineLayer) {
        _arrowBotLineLayer = [CAShapeLayer layer];
        _arrowBotLineLayer.fillColor = nil;
        _arrowBotLineLayer.lineWidth = 1.0;
        _arrowBotLineLayer.strokeColor = AXColor_hex(@"9CA6B9").CGColor;
    }
    return _arrowBotLineLayer;
}

- (UIImageView *)pointImage {
    if (!_pointImage) {
        _pointImage = [[UIImageView alloc] init];
        _pointImage.image = [UIImage imageNamed:@"ax_circle_point"];
    }
    return _pointImage;
}

// MARK: - Set
- (void)setStartAngle:(CGFloat)startAngle {
    if (_startAngle != kDegreesToRadians(startAngle)) {
        _startAngle = kDegreesToRadians(startAngle);
        [self setNeedsLayout];
    }
}

- (void)setReduceAngle:(CGFloat)reduceAngle {
    if (_reduceAngle != kDegreesToRadians(reduceAngle)) {
        if (reduceAngle>=360) {
            return;
        }
        _reduceAngle = kDegreesToRadians(reduceAngle);
        [self setNeedsLayout];
    }
}

- (void)setStrokeWidth:(CGFloat)strokeWidth {
    if (_strokeWidth != strokeWidth) {
        _strokeWidth = strokeWidth;
        _radius = _circleDiameter/2.0 - _strokeWidth/2.0;
        [self setNeedsLayout];
    }
}

- (void)setPathBackColor:(UIColor *)pathBackColor {
    if (_pathBackColor != pathBackColor) {
        _pathBackColor = pathBackColor;
        self.backLayer.strokeColor = _pathBackColor.CGColor;
    }
}

- (void)setPathFillColor:(UIColor *)pathFillColor {
    if (_pathFillColor != pathFillColor) {
        _pathFillColor = pathFillColor;
        self.progressLayer.strokeColor = _pathFillColor.CGColor;
    }
}

- (void)setCanRepeat:(BOOL)canRepeat {
    _canRepeat = canRepeat;
    [self setNeedsLayout];
}

- (void)setShowPoint:(BOOL)showPoint {
    if (_showPoint != showPoint) {
        _showPoint = showPoint;
        if (_showPoint) {
            self.pointImage.hidden = NO;
            self.pointImage.userInteractionEnabled = YES;
            [self.pointImage addGestureRecognizer:self.sliderRec];
            [self updatePointPositionAndStroke];
        } else {
            self.pointImage.hidden = YES;
            self.pointImage.userInteractionEnabled = NO;
            [self.pointImage removeGestureRecognizer:self.sliderRec];
        }
    }
}

// MARK: - 设置进度
- (void)setProgress:(CGFloat)progress {
    _progress = MAX(MIN(1, progress), 0);
    // 更新位置和进度条
    [self updatePointPositionAndStroke];
}

// MARK: - 计算中心点到任意点的角度
static inline float AngleFromNorth(CGPoint p1, CGPoint p2) {
    CGPoint v = CGPointMake(p2.x-p1.x,p2.y-p1.y);
    float vmag = sqrt(SQR(v.x) + SQR(v.y)), result = 0;
    v.x /= vmag;
    v.y /= vmag;
    double radians = atan2(v.y,v.x);
    result = kRadiansToDegrees(radians);
    return (result >=0  ? result : result + 360.0);
}

// MARK: - 计算两点之间距离
static inline float DistanceForTwoPoint(CGPoint p1, CGPoint p2) {
    float xDiff = (float)(p2.x-p1.x);
    float yDiff = (float)(p2.y-p1.y);
    float distance = (float)(sqrt(SQR(xDiff) + SQR(yDiff)));
    return distance;
}

// MARK: - 实时计算滑块的中心位置
- (CGPoint)centerPointForSuperView {
    CGFloat currentEndAngle = (2*M_PI-_reduceAngle)*_progress+_startAngle;
    CGPoint offset = CGPointMake(_radius*cosf(currentEndAngle), _radius*sinf(currentEndAngle));
    return CGPointMake(self.centerPoint.x + offset.x, self.centerPoint.y + offset.y);
}

// MARK: - 获取新的贝塞尔曲线
- (UIBezierPath *)getNewBezierPath {
    return [UIBezierPath bezierPathWithArcCenter:CGPointMake(_circleDiameter/2.0, _circleDiameter/2.0) radius:_radius startAngle:_startAngle endAngle:(2*M_PI-_reduceAngle+_startAngle) clockwise:YES];
}

// MARK: - 获取虚线贝塞尔
- (UIBezierPath *)getDashBezierPath:(CGPoint)startPoint endPoint:(CGPoint)endPoint {
    UIBezierPath *bezierPath = [UIBezierPath bezierPath];
    [bezierPath moveToPoint:startPoint];
    [bezierPath addLineToPoint:endPoint];
    return bezierPath;
}

@end

后记

实现效果怎么说呢?待优化...