自定义环形比例图(Doughnut Chart):从结构到实现

环形比例图实现详解

一. 引言

在实际项目中,环形比例图(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 绘制圆弧

绘制圆环的关键步骤是:

  1. 将百分比转成占比(0~1)
  2. 将占比转成角度 angle = ratio * 2π
  3. 每段加上 gap(间距),通过调整 startAngle / endAngle 实现
  4. 使用 UIBezierPath 绘制圆弧
  5. 设置 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 绘制标注折线

每个段需要绘制一个“小折线”,包括:

  1. 垂直线(沿外法线方向)
  2. 水平线(左 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 桥接等核心实现细节。

虽然图形本身看起来较复杂,但只要掌握“角度计算 → 贝塞尔弧线 → 中心角标注”这条主线,就能够轻松扩展到更多类型的环形图、饼图以及带动画的可视化组件。如果你正在为项目编写类似图表,希望本文的拆解和代码示例能为你提供有价值的参考。

本示例的完整源码已整理到文末的资源区,欢迎参考与使用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值