引言
很久很久以前,设计找到我说:“我们要对APP界面交互进行全流程闭环走查重构”,首当其冲的就是登录注册流程,针对注册流程中的图形验证步骤做了优化,本着“好看”的原则,做出了如下优化,上图:
要求:手指接触中心圆环,手势拖动沿图中虚线拖动到灰色圆环轨道,然后顺时针滑动一周,不足一周恢复初始状态,满足一周业务跳转进行下一步,滑动最多允许一周,不许套圈重复滑动,滑动起始圆环只能沿着虚线方向。OS:之前的不挺好的吗?现实是“美好”的,只能开干。
实现思路
- 背景圆环使用CAShapeLayer+UIBezierPath
- 中间圆环使用图片+手势
- 根据坐标系以及角度限制滑动的有效区域+禁止区域
- 涉及虚线绘制+角度和弧度转换
代码实现
#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&¤tAngle>=0&¤tAngle<=90) || (touchPoint.x>=self.centerPoint.x&&touchPoint.y<self.centerPoint.y&¤tAngle>=340&¤tAngle<=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&¤tAngle <= 45) {
self.lockClockwise = YES;
} else {
self.lockClockwise = NO;
}
// 当前角度大于等于45&&小于50度时,
// 禁止移动到第一、二(小于起始位置的范围)、三、四象限
if (currentAngle >= 45&¤tAngle < 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
后记
实现效果怎么说呢?待优化...