学习绘制iOS雷达图

雷达图

UIBezierPath + CAShaperLayer绘制,先给展示一张最终的效果图,然后咱们慢慢来说思路

雷达图最终效果

步骤 + 思路

  1. 绘制背景(蜘蛛网效果)和 分类属性名的绘制

外层的多边形:把雷达图看成一个圆形,最外层的点都是在圆上的,这样我们就可以在圆上找多个点,然后连接在一起,去绘制了。


圆形的路径

首先创建一个RadarChartView,开始着手,最外层边框线的绘制,先要根据分类的数据才能确定圆上有几个点。

- (void)fl_drawRadarChartBorderLine {
    CAShapeLayer *layer = [CAShapeLayer layer];
    //线条宽度
    layer.lineWidth = 1.0;
    //线条颜色
    layer.strokeColor = [UIColor darkGrayColor].CGColor;
    //填充颜色
    layer.fillColor = [UIColor clearColor].CGColor;
    
    UIBezierPath *path = [UIBezierPath bezierPath];
    //拿到view的中心点坐标chartCenter,不能用self.center,这个获取的是view在父视图中的坐标
    CGPoint chartCenter = CGPointMake(self.bounds.size.width / 2, self.bounds.size.height / 2);
    //假数据
    NSArray *dataArray = @[@"Objective-C",@"Swift",@"Python",@"Java",@"C",@"c++"];
    //圆半径
    CGFloat radius = MIN(self.bounds.size.width, self.bounds.size.height) / 2 - 20;
    //子角度
    //1/360 的角度
    NSInteger subAngle = 360 / dataArray.count;
    
    for (NSInteger i = 0; i < dataArray.count; i ++) {
        //角度
        NSInteger angle = i * subAngle;
        //弧度  角度转弧度
        CGFloat radian = (M_PI * (angle) / 180.0);
        //弧度转坐标
        CGFloat x = chartCenter.x + sinf(radian) * radius;
        CGFloat y = chartCenter.y - cosf(radian) * radius;
        CGPoint point = CGPointMake(x, y);
        if (i == 0) {
            //第一个点
            [path moveToPoint:point];
        } else {
            [path addLineToPoint:point];
        }
    }
    //将路径闭合,即将起点和终点连起
    [path closePath];
    layer.path = path.CGPath;
    [self.layer addSublayer:layer];
}
//用drawRect开始绘制
- (void)drawRect:(CGRect)rect {
    // Drawing code
    [self fl_drawRadarChartBorderLine];
}
边框线条绘制效果

雷达图的雏形就已经绘制好了,现在开始绘制每一层的线条

- (void)fl_drawRadarChartBorderBackgroundLine {
    CAShapeLayer *layer = [CAShapeLayer layer];
    //线条宽度
    layer.lineWidth = 1.0;
    //线条颜色
    layer.strokeColor = [UIColor darkGrayColor].CGColor;
    //填充颜色
    layer.fillColor = [UIColor clearColor].CGColor;
    
    UIBezierPath *path = [UIBezierPath bezierPath];
    //拿到view的中心点坐标chartCenter,不能用self.center,这个获取的是view在父视图中的坐标
    CGPoint chartCenter = CGPointMake(self.bounds.size.width / 2, self.bounds.size.height / 2);
    //假数据
    NSArray *dataArray = @[@"Objective-C",@"Swift",@"Python",@"Java",@"C",@"c++"];
    //圆半径
    CGFloat radius = MIN(self.bounds.size.width, self.bounds.size.height) / 2 - 20;
    //子角度
    //1/360 的角度
    NSInteger subAngle = 360 / dataArray.count;
    //每条线的间隔
    CGFloat lineInterval = 20.0;
    
    //雷达图间隔可以画的次数,向上取整
    NSInteger lines = ceilf(radius / lineInterval);
    //保存最外层的坐标点,可用于绘制竖直的线条
    NSMutableArray *borderPointArray = [NSMutableArray array];
    //根据可绘制的线条数量循环
    for (NSInteger idx = 0; idx < lines; idx ++) {
        //循环绘制每一圈的背景线
        NSMutableArray<NSValue *> *pointArray = [NSMutableArray array];
        for (NSInteger i = 0; i < dataArray.count; i ++) {
            //角度
            NSInteger angle = i * subAngle;
            //弧度  角度转弧度
            CGFloat radian = (M_PI * (angle) / 180.0);
            //弧度转坐标
            CGFloat x = chartCenter.x + sinf(radian) * radius;
            CGFloat y = chartCenter.y - cosf(radian) * radius;
            CGPoint point = CGPointMake(x, y);
            [pointArray addObject:[NSValue valueWithCGPoint:point]];
            if (i == 0) {
                //第一个点
                [path moveToPoint:point];
            } else {
                [path addLineToPoint:point];
            }
        }
        //将路径闭合,即将起点和终点连起
        [path closePath];
        //减去线条间隔
        radius -= lineInterval;
        if (idx == 0) {
            //获取最外层的坐标数组
            [borderPointArray setArray:pointArray];
        }
    }
    //竖向直线
    for (NSValue *boardValue in borderPointArray) {
        CGPoint boardPoint = boardValue.CGPointValue;
        //连接最外层坐标点和雷达图中心点
        [path moveToPoint:boardPoint];
        [path addLineToPoint:chartCenter];
    }
    
    layer.path = path.CGPath;
    [self.layer addSublayer:layer];
}

蜘蛛网图绘制效果

绘制完成后,开始着手分类属性名称的绘制,这里可以使用UILabelCATextLayerUILabel就不用讲太多了,既然是图表,还是选择CATextLayer

最外层的坐标点数组,这里就还可以用来计算每个分类属性文字的位置。计算文字的大小可以用系统的

- (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options attributes:(nullable NSDictionary<NSAttributedStringKey, id> *)attributes context:(nullable NSStringDrawingContext *)context NS_AVAILABLE(10_11, 7_0);

或是

- (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options context:(nullable NSStringDrawingContext *)context NS_AVAILABLE(10_11, 6_0);

接下来绘制雷达图分类属性文字,根据边框上的点来计算,文本应该显示的位置,下面的代码,进行了封装,和上面无异


根据中心点的位计算文本位置
/**
 绘制雷达图分类属性文字
 
 @param pointArray 每个角的坐标数组
 */
- (void)fl_drawRadarChartClassifyTextWithPointArray:(NSArray<NSValue *> *)pointArray {
    for (NSInteger j = 0; j < pointArray.count; j ++) {
        //边框的位置
        CGPoint borderPoint = pointArray[j].CGPointValue;
        
        CATextLayer *textLayer = nil;
        //文字
        NSString *text = self.classifyDataArray[j];
        //文字大小
        CGSize textSize = [text fl_sizeForFont:self.classifyTextFont];
        //文字的间隔
        CGFloat textInterval = 10;
        
        if (borderPoint.x < self.chartCenter.x && (self.chartCenter.x - borderPoint.x) > 0.05) {
            //判断是否在圆中心点的左侧,多加一个判断条件,主要用于判断文本上下两点的,边框绘制的点和实际chartCenter.有一点点偏差
            CGRect frame = CGRectMake(borderPoint.x - textSize.width - textInterval, borderPoint.y - textSize.height / 2, textSize.width, textSize.height);
            textLayer = [self fl_getTextLayerWithString:text backgroundColor:[UIColor clearColor] frame:frame];
            
        } else if (borderPoint.x > self.chartCenter.x && (borderPoint.x - self.chartCenter.x) > 0.05) {
            //判断是否在圆中心点的右侧
            CGRect frame = CGRectMake(borderPoint.x + textInterval, borderPoint.y - textSize.height / 2, textSize.width, textSize.height);
            textLayer = [self fl_getTextLayerWithString:text backgroundColor:[UIColor clearColor] frame:frame];
            
        } else  { //textPoint.x == self.chartCenter.x
            if (borderPoint.y < self.chartCenter.y) {
                //判断是否在圆中心点的正上方
                CGRect frame = CGRectMake(borderPoint.x - textSize.width / 2, borderPoint.y - textSize.height - textInterval, textSize.width, textSize.height);
                textLayer = [self fl_getTextLayerWithString:text backgroundColor:[UIColor clearColor] frame:frame];
                
            } else {
                //判断是否在圆中心点的正下方
                CGRect frame = CGRectMake(borderPoint.x - textSize.width / 2, borderPoint.y + textInterval, textSize.width, textSize.height);
                textLayer = [self fl_getTextLayerWithString:text backgroundColor:[UIColor clearColor] frame:frame];
                
            }
        }
        [self.backgroundLineLayer addSublayer:textLayer];
    }
}


/**
 获取CATextLayer

 @param text 文本
 @param backgroundColor 背景色
 @param frame 位置大小
 @return CATextLayer
 */
- (CATextLayer *)fl_getTextLayerWithString:(NSString *)text backgroundColor:(UIColor *)backgroundColor frame:(CGRect)frame {
    //初始化一个CATextLayer
    CATextLayer *textLayer = [CATextLayer layer];
    //设置文字frame
    textLayer.frame = frame;
    //设置文字
    textLayer.string = text;
    //设置文字大小
    textLayer.fontSize = self.classifyTextFont.pointSize;
    //设置文字颜色
    textLayer.foregroundColor = self.classifyTextColor.CGColor;
    //设置背景颜色
    textLayer.backgroundColor = backgroundColor.CGColor;
    //设置对齐方式
    textLayer.alignmentMode = kCAAlignmentCenter;
    //设置分辨率
    textLayer.contentsScale = [UIScreen mainScreen].scale;
    return textLayer;
}
雷达图底层绘制效果
  1. 根据数据绘制数据图层
    数据无非就是0-100、0-1或是100-1000等之间的数值,并不确定,反观雷达图中心点肯定就是最小值,边框就是最大值,所以我们需要确定图表中的minValuemaxValue,每个图层都有它的名称,线条颜色,填充颜色和数值,数值的数量基本是和分类属性数量是一致的,所以我们写了个FLRadarChartModel,定义了以下四个个属性。
/**
 数值数组
 */
@property (nonatomic, strong) NSArray<NSNumber *> *valueArray;

/**
 名称
 */
@property (nonatomic, strong) NSString *name;

/**
 绘制颜色
 */
@property (nonatomic, strong) UIColor *strokeColor;

/**
 填充颜色
 */
@property (nonatomic, strong) UIColor *fillColor;

然后就可以根据我们自己定义的model来进行多层绘制。初始化三个假数据

FLRadarChartModel *model_1 = [[FLRadarChartModel alloc]init];
model_1.name = @"平均能力";
model_1.valueArray = @[@50,@100,@80,@35,@10,@65];
model_1.strokeColor = [UIColor fl_colorWithHexString:@"00A8FF"];
model_1.fillColor = [UIColor fl_colorWithHexString:@"00A8FF" alpha:0.8];

FLRadarChartModel *model_2 = [[FLRadarChartModel alloc]init];
model_2.name = @"个人能力";
model_2.valueArray = @[@60,@50,@30,@100,@90,@75,];
model_2.strokeColor = [UIColor fl_colorWithHexString:@"FED700"];
model_2.fillColor = [UIColor fl_colorWithHexString:@"FED700" alpha:0.8];

FLRadarChartModel *model_3 = [[FLRadarChartModel alloc]init];
model_3.name = @"屌丝能力";
model_3.valueArray = @[@20,@30,@40,@50,@60,@25,];
model_3.strokeColor = [UIColor fl_colorWithHexString:@"FFC0CB"];
model_3.fillColor = [UIColor fl_colorWithHexString:@"FFC0CB" alpha:0.8];
        
self.dataArray = @[model_1, model_2, model_3];
/**
 绘制雷达图
 */
- (void)fl_drawRadarChartWithValue {
    for (FLRadarChartModel *dataModel in self.dataArray) {
        
        CAShapeLayer *layer = [CAShapeLayer layer];
        layer.lineWidth = self.radarChartLineWidth;
        layer.strokeColor = dataModel.strokeColor.CGColor;
        layer.fillColor = dataModel.fillColor.CGColor;
        UIBezierPath *bezierPath = [UIBezierPath bezierPath];
        
        //不能使用forin遍历 如果数组中存在相同数据 indexOfObject 获取的 index 是错误的
        for (NSInteger i = 0; i < dataModel.valueArray.count; i ++) {
            NSNumber *value = dataModel.valueArray[i];
            CGFloat numeric = value.floatValue;
            
            //是否允许数据溢出
            if (!self.allowOverflow) {
                //判断当前值是否超过最大最小值
                if (value.floatValue > self.maxValue) {
                    numeric = MIN(value.floatValue, self.maxValue);
                } else if (value.floatValue < self.minValue) {
                    numeric = MAX(value.floatValue, self.minValue);
                }
            }
            
            NSInteger subAngle = 360 / self.classifyDataArray.count;
            
            //error:NSInteger index = [dataModel.valueArray indexOfObject:value];
            
            NSInteger angle = i * subAngle;
            CGFloat radian = kDegreesToRadian(angle);
            //每个数值的最小半径
            CGFloat minValueRadius = (self.maxValue - self.minValue) / self.chartRadius;
            
            CGFloat x = self.chartCenter.x + sinf(radian) * (numeric / minValueRadius);
            CGFloat y = self.chartCenter.y - cosf(radian) * (numeric / minValueRadius);
            
            CGPoint valuePoint = CGPointMake(x, y);
            if (i == 0) {
                [bezierPath moveToPoint:valuePoint];
            } else {
                [bezierPath addLineToPoint:valuePoint];
            }
        }
        
        [bezierPath closePath];
        layer.path = bezierPath.CGPath;
        [self.valueLayer addSublayer:layer];
    }
    [self.layer addSublayer:self.valueLayer];
}

已经绘制好数据图层了,就还剩图层颜色和名称的描述,我将颜色和文字描述定位在view的右下角。颜色圆形用CAShapeLayer,文字用CATextLayer

/**
 绘制颜色和文字描述
 */
- (void)fl_drawRadarChartWithColorDescribe {
    for (FLRadarChartModel *model in self.dataArray) {
        NSInteger index = [self.dataArray indexOfObject:model];
        CGSize textSize = [model.name fl_sizeForFont:self.classifyTextFont];
        CGFloat textInterval = 5;
        
        //计算文字的绘制位置
        CGRect textFrame = CGRectMake(self.fl_width - textSize.width - 3 * textInterval, self.fl_height - (index + 1) * textSize.height - 2 * (index + 1) * textInterval, textSize.width, textSize.height);
        //计算颜色圆形的绘制位置
        CGRect colorCircleFrame = CGRectMake(textFrame.origin.x - textInterval - textSize.height, textFrame.origin.y, textSize.height, textSize.height);
        
        CAShapeLayer *colorCircleLayer = [self fl_getColorCircleLayerWithColor:model.strokeColor frame:colorCircleFrame];
        [self.colorDescribeLayer addSublayer:colorCircleLayer];
        
        CATextLayer *colorDescribeLayer = [self fl_getTextLayerWithString:model.name backgroundColor:[UIColor clearColor] frame:textFrame];
        [self.colorDescribeLayer addSublayer:colorDescribeLayer];
    }
    [self.layer addSublayer:self.colorDescribeLayer];
}

- (CAShapeLayer *)fl_getColorCircleLayerWithColor:(UIColor *)color frame:(CGRect)frame {
    CAShapeLayer *colorCircleLayer = [CAShapeLayer layer];
    colorCircleLayer.fillColor = [color CGColor];
    colorCircleLayer.strokeColor = [color CGColor];
    colorCircleLayer.lineCap = kCALineCapRound;
    colorCircleLayer.lineWidth = 1;
    UIBezierPath *colorCircleLayerPath = [UIBezierPath bezierPathWithOvalInRect:frame];
    colorCircleLayer.path = colorCircleLayerPath.CGPath;
    return colorCircleLayer;
}

最终效果如图

最终效果图

雷达图的显示基本上绘制完成了。希望能对大家有用。
最后附上demo地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容