
一. 引言
在实际项目中,环形比例图(Doughnut Chart)是一类非常常见的可视化组件。无论是占比展示、胜平负预测、评分分布,还是业务数据分析,都经常会用到类似的“多段圆环 + 外部标注”的图形。虽然它看起来有一定的几何和绘图逻辑,但实现本身并不复杂。本文将从“图形结构”开始,层层拆解如何在 iOS 中用 UIKit + Core Graphics 绘制一个带标注、带间距、支持 SwiftUI 的环形比例图,并结合示例代码逐段解析其核心实现细节。
二. 图形布局结构解析
我们先来分析一下图形的布局结构,以方便我们清楚要做哪些内容,图形其实主要有5个要素组成:
1. 圆环主体(多段组成):
- 一个圆环被划分为多段,弧长与对应占比成正比。
- 段与段之间有可见的间距(gap),使视觉区分更清晰。
2. 段落间距(Gaps)
- 每段弧线的起始角和结束角都向内缩一点形成留白。
3. 小标注(Callout)
每段包含一个“折线 + 文本”的标注:
- 从该段中心角沿外法线绘制一条短的垂直线;
- 再接一段左右方向的水平线;
- 文本标签(如“胜 60%”)放在水平线末端。
4.中心标题
- 一个简单的居中文字用于显示当前图表的类型或含义。
5. 颜色与布局细节
- 每段颜色独立;
- 文本颜色偏灰、保证轻量的视觉风格;
- 整个视图背景透明,便于嵌入任意页面。
这其中比较重要的内容一个是圆环主体,段落间距,以及标注部分。
三. 实现思路解析
在这里我们首先来解析一下三个主要部分的实现思路,可以帮助我们在实现过程中思路更清晰。
1. 绘制圆环及gap
绘制圆环的核心方法是使用贝塞尔曲线的UIBezierPath(arcCenter:radius:startAngle:endAngle:clockwise:) 方法来绘制一段一段的圆弧。
- 输入数据转换为占比;
- 计算每段弧长角度;
- 每段起止角扣掉一点 gap;
- 设置 lineWidth 实现环形粗细。
2. 绘制标注折线
折线呢我们首先要获取每段圆弧的中心切线,然后计算出法线,绘制出一小段法线之后呢,再绘制出一点左右方向的水平线。
- 段中心角 midAngle = (start' + end') / 2;
- 沿该方向计算外法线(与切线垂直);
- 从圆环外边界先延伸一小段;
- 再画一条左右方向的水平线;
- 根据 cos(midAngle) 判断属于左侧还是右侧。
3. 标签定位
标签虽然只是一个普通的UILabel但是位置计算我们仍然需要花点心思。
- 标签中心点跟随水平线末端左右偏移 10px;
- 再加半个 Label 宽度,确保视觉上居中;
四. 代码实现与核心逻辑解析
为了让代码结构更易理解,本节将环形比例图的实现拆分为五个部分,分别对应 UI 结构、圆弧绘制、标注折线绘制、标签位置计算以及 SwiftUI 桥接。
整个实现基于 ZMSkyEyeDoughnutUIView,并通过 UIViewRepresentable 暴露给 SwiftUI 使用。
4.1 视图整体结构(属性、初始化、setupView)
首先定义该视图的基本属性,包括:
- 数据(百分比)
- 每段颜色
- 类型(标题)
- 三个标签(胜、平、负)
- 中心标题
并在 init 中进行赋值,随后在 setupView 中完成 UI 元素的初始化与样式设置。
示例代码(结构部分):
class ZMSkyEyeDoughnutUIView: UIView {
var array: [Int]
var colors: [UIColor]
var predictType: ZMAIPredictType
private let labelWidth = 26.0
private let labelHeight = 34.0
private let centerLabel = UILabel()
private let winLabel = UILabel()
private let drawLabel = UILabel()
private let loseLabel = UILabel()
init(frame: CGRect, array: [Int], colors: [UIColor], predictType: ZMAIPredictType) {
self.array = array
self.colors = colors
self.predictType = predictType
super.init(frame: frame)
setupView()
}
required init?(coder: NSCoder) { ... }
private func setupView() {
// 处理标题和三个标签的初始化
// 布局统一在这里完成
// draw() 会负责图形绘制
}
override func draw(_ rect: CGRect) {
super.draw(rect)
// 具体绘制逻辑拆分到 4.2 - 4.4 部分说明
}
}
这一部分只负责 UI 与属性,不负责“画图本身”。所有核心绘制逻辑都在 draw(_:) 中。
4.2 绘制圆弧
绘制圆环的关键步骤是:
- 将百分比转成占比(0~1)
- 将占比转成角度 angle = ratio * 2π
- 每段加上 gap(间距),通过调整 startAngle / endAngle 实现
- 使用 UIBezierPath 绘制圆弧
- 设置 lineWidth 来形成环形
示例代码(圆弧部分):
let ratios = array.map { Double($0) / 100.0 }
var startAngle = -CGFloat.pi / 2 - .pi / 8
let gapAngle: CGFloat = .pi / 30
for (index, value) in ratios.enumerated() {
let segmentAngle = CGFloat(value) * 2 * .pi
let adjustedStart = startAngle + gapAngle / 2
let adjustedEnd = startAngle + segmentAngle - gapAngle / 2
let path = UIBezierPath(
arcCenter: centerPoint,
radius: radius,
startAngle: adjustedStart,
endAngle: adjustedEnd,
clockwise: true
)
path.lineWidth = lineWidth
colors[index].setStroke()
path.stroke()
startAngle += segmentAngle
}
主要视觉效果在于:
- 使用 gapAngle 让段之间产生可见缝隙
- 通过 lineWidth 形成厚环形
- 起始角稍微偏移,便于控制标注位置
4.3 绘制标注折线
每个段需要绘制一个“小折线”,包括:
- 垂直线(沿外法线方向)
- 水平线(左 or 右)
核心依据是该段的 中心角 midpointAngle。
示例代码(折线部分):
let midAngle = (adjustedStart + adjustedEnd) / 2
let startX = center.x + cos(midAngle) * (radius + lineWidth/2 + 3)
let startY = center.y + sin(midAngle) * (radius + lineWidth/2 + 3)
let endX = center.x + cos(midAngle) * (radius + lineWidth/2 + 8)
let endY = center.y + sin(midAngle) * (radius + lineWidth/2 + 8)
// 垂线
let linePath = UIBezierPath()
linePath.move(to: CGPoint(x: startX, y: startY))
linePath.addLine(to: CGPoint(x: endX, y: endY))
linePath.lineWidth = 1.5
colors[index].setStroke()
linePath.stroke()
// 水平线
let horizontalLength: CGFloat = 5
let horizontalEndX = endX + (cos(midAngle) >= 0 ? horizontalLength : -horizontalLength)
设计意图:
- 使用 cos(midAngle) 判断左右区域
- 让折线末端“向外伸”,为 label 提供放置空间
4.4 计算标签(Label)位置
标签跟随折线末端定位。
规则:
- 左侧段落:label 在水平线左端外侧
- 右侧段落:label 在水平线右端外侧
- 垂直方向保持与折线末端对齐
- label 有固定宽高,使用 center 定位即可
示例代码(label 部分):
let isLeft = cos(midAngle) < 0
let labelCenterX = isLeft
? horizontalEndX - (10 + labelWidth/2)
: horizontalEndX + (10 + labelWidth/2)
label.center = CGPoint(x: labelCenterX, y: endY)
4.5 SwiftUI 桥接层
如果需要再SwiftUI页面展示,我们需要使用UIViewRepresentable做包装。
示例代码(SwiftUI 包装):
struct ZMSkyEyeDoughnutChartView: UIViewRepresentable {
var predictType: ZMAIPredictType
var array: [Int]
var colors: [UIColor]
func makeUIView(context: Context) -> ZMSkyEyeDoughnutUIView {
let view = ZMSkyEyeDoughnutUIView(
frame: .zero,
array: array,
colors: colors,
predictType: predictType
)
view.backgroundColor = .clear
return view
}
func updateUIView(_ uiView: ZMSkyEyeDoughnutUIView, context: Context) {
uiView.array = array
uiView.colors = colors
uiView.predictType = predictType
uiView.setNeedsDisplay()
}
}
SwiftUI 用户可以直接这样使用:
ZMSkyEyeDoughnutChartView(
predictType: .spf,
array: [60, 25, 15],
colors: [.red, .blue, .green]
)
五. 结语
环形比例图是一类在业务场景中非常常见的数据可视化组件。无论是做占比分析、状态分布展示,还是构建预测类图表,它都具有良好的信息密度与可读性。本文从图形结构入手,拆解了每个视觉元素背后的几何逻辑,并逐步展开了圆弧绘制、段落间距、标注折线、标签定位以及 SwiftUI 桥接等核心实现细节。
虽然图形本身看起来较复杂,但只要掌握“角度计算 → 贝塞尔弧线 → 中心角标注”这条主线,就能够轻松扩展到更多类型的环形图、饼图以及带动画的可视化组件。如果你正在为项目编写类似图表,希望本文的拆解和代码示例能为你提供有价值的参考。
本示例的完整源码已整理到文末的资源区,欢迎参考与使用。
环形比例图实现详解
1006

被折叠的 条评论
为什么被折叠?



