数据可视化是移动应用里非常常见的需求,而环形统计图(也叫甜甜圈图)因为直观美观,被大量用在健康、金融、运动类App的首页展示中。实现环形图的核心技术点有三个:一是把百分比换算成弧度并正确绘制圆弧,二是把百分比文本绘制到合适的位置,三是让图形以动画的形式逐步呈现。本文将以Core Graphics为核心,结合CAShapeLayer,把这三件事一次讲透。

一、弧度计算:环形图绘制的数学基础
Core Graphics中绘制圆弧使用的函数是CGContextAddArc或者UIBezierPath的addArc系列方法,它们接收的参数是弧度而不是角度,所以第一步必须搞清楚两者的换算关系。一个完整的圆是360度,对应弧度为2π,因此每1度对应π/180弧度。假设某个分类占总量的30%,那么它对应的弧度就是2π × 0.3。
绘制环形图还需要明确起点方向。iOS的坐标系原点在左上角,y轴向下,Core Graphics中0弧度位于圆心正右方,正方向为顺时针(注意UIBezierPath的clockwise参数在iOS坐标系中实际表现为顺时针还是逆时针容易让人混淆,官方文档有专门说明)。为了让环形图看起来像仪表盘一样从顶部开始,通常会把起始角设为-π/2,也就是圆心正上方。
计算每个扇区时,采用累加的方式:维护一个变量startAngle,每绘制完一段就把它加上当前分类的弧度值,作为下一段的起点。这样即使某个分类占比极小,也能保证各扇区首尾相接,不会出现缝隙或重叠。
// 数据模型
@interface PieItem : NSObject
@property (nonatomic, assign) CGFloat value; // 数值
@property (nonatomic, strong) UIColor *color; // 扇区颜色
@end
// 弧度计算核心逻辑
CGFloat total = 0;
for (PieItem *item in self.items) {
total += item.value;
}
CGFloat startAngle = -M_PI_2; // 从顶部开始
CGFloat lineWidth = 20; // 环形线条宽度
CGContextRef ctx = UIGraphicsGetCurrentContext();
CGContextSetLineWidth(ctx, lineWidth);
CGContextSetLineCap(ctx, kCGLineCapButt); // 扇区之间平齐衔接
for (PieItem *item in self.items) {
CGFloat sweep = item.value / total * M_PI * 2;
CGFloat endAngle = startAngle + sweep;
UIBezierPath *arc = [UIBezierPath bezierPathWithArcCenter:self.center
radius:self.bounds.size.height * 0.35
startAngle:startAngle
endAngle:endAngle
clockwise:YES];
[item.color setStroke];
[arc stroke];
startAngle = endAngle;
}
这里有几个细节值得注意。半径一般取控件短边的一半再减去线宽的一半,避免线条被裁剪;线帽样式如果用kCGLineCapRound,相邻扇区交界处会出现圆头重叠,视觉上更柔和但小占比扇区会被挤占,需要根据设计稿权衡;另外每个扇区之间如果想留出白色间隔,可以在endAngle上减去一个固定的小弧度,比如0.02弧度。
二、百分比文本的定位与绘制
文本绘制分两种场景:一种是绘制在环形中心,显示总量或标题;另一种是绘制在各个扇区的中点方向上,显示该分类的百分比。中心文本比较简单,直接用NSAttributedString配合drawAtPoint即可,但扇区文本需要先算出该扇区角度中点对应的坐标。
计算方法是:取该扇区的起始角与结束角的平均值,得到中点角midAngle,然后利用三角函数求出圆上任意一点的坐标。公式为x = centerX + radius × cos(midAngle),y = centerY + radius × sin(midAngle)。由于iOS坐标系y轴朝下,这个公式无需再做符号修正,直接套用即可。如果希望文本放在环线上,radius取绘制半径;如果希望放在环外側,radius再加上线宽的一半加一点边距。
- (void)drawTextForItem:(PieItem *)item
startAngle:(CGFloat)startAngle
endAngle:(CGFloat)endAngle {
CGFloat midAngle = (startAngle + endAngle) / 2;
CGFloat textRadius = self.bounds.size.height * 0.35 + 25; // 放在环外侧
CGPoint center = CGPointMake(CGRectGetMidX(self.bounds), CGRectGetMidY(self.bounds));
CGPoint textPoint = CGPointMake(center.x + textRadius * cos(midAngle),
center.y + textRadius * sin(midAngle));
CGFloat percent = item.value / self.totalValue * 100;
NSString *text = [NSString stringWithFormat:@"%.0f%%", percent];
NSDictionary *attrs = @{NSFontAttributeName : [UIFont boldSystemFontOfSize:13],
NSForegroundColorAttributeName : item.color};
CGSize textSize = [text sizeWithAttributes:attrs];
// 以文本中心点对齐到计算出的坐标
[text drawAtPoint:CGPointMake(textPoint.x - textSize.width / 2,
textPoint.y - textSize.height / 2)
withAttributes:attrs];
}
实际项目中还有两个坑要注意。第一,当某个分类占比小于5%时,文本很可能与相邻文本重叠,常见做法是把小占比扇区的文本用引导线拉到环外,或者干脆只在占比大于阈值时才显示文本。第二,drawRect内的绘制发生在CPU上,如果文本较多且视图频繁刷新,可以考虑只把文本放到Core Text或者用CATextLayer承载,避免重复布局计算。
三、用CAShapeLayer实现路径修剪动画
drawRect方式绘制出来的环形图是静态的,用户进入页面时如果图形能从0逐步展开到目标状态,体验会好很多。这正是CAShapeLayer的strokeEnd属性的用武之地,它可以让路径只描边到指定比例的位置,配合CABasicAnimation就能实现平滑的展开动画,而且整个动画由Core Animation在渲染进程执行,完全不占用主线程。
思路是把所有扇区合并成一条路径是不行的,因为不同扇区颜色不同,而CAShapeLayer的strokeColor只有一个。正确做法是为每个扇区创建一个独立的CAShapeLayer,各自持有自己的路径,然后用strokeStart和strokeEnd控制每段弧的可见范围。由于每个扇区的路径本身就是从startAngle画到endAngle的一段弧,所以动画时只需要让strokeEnd从0变到1即可。
如果想让扇区一个接一个展开而不是同时展开,可以利用CABasicAnimation的beginTime做延迟:第n个扇区的动画开始时间设置为当前时间加上前面所有扇区动画时长之和。这样整体效果就是顺时针逐段生长,非常流畅自然。
- (void)startAnimation {
CFTimeInterval currentTime = CACurrentMediaTime();
CGFloat total = self.totalValue;
CGFloat startAngle = -M_PI_2;
CGFloat accumulatedDuration = 0;
CGFloat baseDuration = 1.2; // 整体动画时长
for (PieItem *item in self.items) {
CGFloat sweep = item.value / total * M_PI * 2;
CGFloat endAngle = startAngle + sweep;
UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:self.arcCenter
radius:self.arcRadius
startAngle:startAngle
endAngle:endAngle
clockwise:YES];
CAShapeLayer *layer = [CAShapeLayer layer];
layer.path = path.CGPath;
layer.fillColor = [UIColor clearColor].CGColor;
layer.strokeColor = item.color.CGColor;
layer.lineWidth = 20;
layer.lineCap = kCALineCapButt;
layer.strokeEnd = 0; // 初始不可见
[self.layer addSublayer:layer];
CGFloat duration = baseDuration * (item.value / total);
CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
anim.fromValue = @0;
anim.toValue = @1;
anim.duration = duration;
anim.beginTime = currentTime + accumulatedDuration;
anim.fillMode = kCAFillModeForwards;
anim.removedOnCompletion = NO;
anim.delegate = self;
[layer addAnimation:anim forKey:@"reveal"];
// 模型层也要同步设置,避免动画结束后回退
[CATransaction begin];
[CATransaction setDisableActions:YES];
layer.strokeEnd = 1;
[CATransaction commit];
accumulatedDuration += duration;
startAngle = endAngle;
}
}
这段代码有一个容易被忽略的点:removedOnCompletion和模型层的同步设置必须配合使用。只设置前者会在某些情况下留下隐式动画残留,只设置模型层则会看到属性瞬间跳变。用CATransaction关闭隐式动画后再赋值,能保证动画结束时状态稳定。
四、方案选型与进阶优化
纯drawRect方案和CAShapeLayer方案各有适用场景。drawRect适合静态图表,配合UIGraphicsImageRenderer可以提前把图形渲染成图片缓存起来,滚动列表中复用性能极好;CAShapeLayer适合需要动画、需要交互(比如点击某个扇区高亮)的场景,每个扇区独立一个layer,点击命中判断可以直接用CGPathContainsPoint完成。
进阶方向上,如果设计要求渐变色扇区,可以给CAShapeLayer叠加CAGradientLayer做mask,让渐变沿着圆弧方向流动;如果数据会动态变化,可以监听数据源变化后重新计算路径,并用CADisplayLink驱动插值,实现数值变化时扇区平滑过渡的刷新效果。此外,绘制超大弧度(接近整圆)的扇区时,由于路径起始与结束点重合可能导致描边缺失,此时应把该扇区拆成两段弧绘制,或者让endAngle略微小于startAngle加2π再单独补上一小段。
总结一下,环形统计图的实现难度不在绘图API本身,而在于弧度换算的准确性和动画状态的管理。把文中这三块内容组合起来,一个支持动画、支持点击交互、数据驱动的环形图组件就有了完整的骨架,直接封装成UIView子类就能在项目中复用。
Core Graphics环形统计图iOS绘图修改时间:2026-09-16 20:28:55