导读:本期聚焦于星河创作的《iOS环形统计图怎么画?Core Graphics弧度计算、文本绘制与动画实现详解》,敬请观看详情。想在iOS应用里做出一个带动画效果的环形统计图,却不知道从哪里下手?本文基于Core Graphics框架,完整讲解环形图的实现思路:先理清角度与弧度的换算关系,掌握各扇区起始角与扫过角的计算方法;再介绍用UIBezierPath配合CGContext完成圆弧描边与颜色填充,并在合适的位置绘制百分比文本;最后利用CAShapeLayer的strokeEnd属性实现路径修剪动画,让环形图以平滑过渡的方式逐段展开。文中还涉及线帽样式、渐变色配置、性能优化等细节,并对比了Core Graphics与CALayer两种方案的适用场景,代码可直接集成到自定义View中使用。

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

iOS环形统计图怎么画?Core Graphics弧度计算、文本绘制与动画实现详解

一、弧度计算:环形图绘制的数学基础

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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0916/58170.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。