用 Core Animation 做足球数据可视化:从圆环和条形图的真实案例

一. 引言

在足球比赛的详情页中,通常都会有一个双方数据面板,用来展示主队和客队在比赛过程中的对比情况。

这个页面中包含了多种类型的数据,比如进攻次数、危险进攻、控球率等。这类数据更关注的是双方的占比关系,因此在 UI 上使用了一个环形的占比视图来进行展示,能够让用户快速感知哪一方在场面上更占优势。

另外,还有一些偏数量型的数据,比如射正球门、射偏球门。这类数据除了对比比例之外,数值本身也很重要,在 UI 上使用的是横向条形占比视图。

至于页面中其它部分,比如球队图标、文字信息等,基本都是常规的图片和文本布局,实现方式比较常见。

因此这篇博客主要聚焦在两个核心点上:

  • 环形占比视图的实现
  • 条形占比视图的实现

下面就从环形占比视图开始,来看这个 UI 是如何一步步实现出来的。

二. 环形占比视图的实现

环形占比视图在这个页面中被多次使用,比如控球率、进攻、危险进攻等。在实现上,我们将它封装成了一个独立的 UIView 子类,只关心一件事情:根据主队和客队的数据,绘制对应比例的环形路径。

2.1 视图结构

整个环形视图内部由两个 CAShapeLayer 组成,分别代表主队和客队:

Swift 复制代码
class ZMFootBallMatchStatusDonutChart: UIView {

    private let homeLayer = CAShapeLayer()
    private let awayLayer = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupLayer()
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }

每一方各自对应一条圆弧路径,二者共用同一个圆心和半径,只是绘制的方向和角度不同。

2.2 图层的基础配置

在 setupLayer 中,对两个 CAShapeLayer 做统一的基础配置:

Swift 复制代码
private func setupLayer() {
    [homeLayer, awayLayer].forEach {
        $0.fillColor = UIColor.clear.cgColor
        $0.lineWidth = 6
        $0.lineCap = .butt
        layer.addSublayer($0)
    }
    
    homeLayer.strokeColor = UIColor.red.cgColor
    awayLayer.strokeColor = UIColor.blue.cgColor
}

这里有几个关键点:

  • fillColor 设置为透明,只显示描边
  • lineWidth 控制圆环的粗细
  • 使用两个不同的 strokeColor 区分主队和客队
  • 将 CAShapeLayer 添加到视图的 layer 上

这样就准备好了绘制路径所需的基础条件。

2.3 根据数据绘制环形路径

核心逻辑在 render(home:away:) 方法中:

Swift 复制代码
func render(home: Double, away: Double) {
    guard home + away > 0 else { return }
    
    let total = home + away
    let homeRatio = home / total
    let awayRatio = away / total
    
    let center = CGPoint(x: bounds.midX, y: bounds.midY)
    let radius = min(bounds.width, bounds.height) / 2 - 3
    let startAngle = -CGFloat.pi / 2
...
}

首先计算总量和双方的比例,然后统一定义圆心、半径和起始角度。

起始角度选择 -π/2,也就是从正上方开始绘制,这是环形图中比较常见的起点。

接下来分别生成主队和客队的圆弧路径:

Swift 复制代码
// 主队(逆时针)
let homePath = UIBezierPath(
    arcCenter: center,
    radius: radius,
    startAngle: startAngle,
    endAngle: startAngle - 2 * .pi * homeRatio,
    clockwise: false
)

// 客队(顺时针)
let awayPath = UIBezierPath(
    arcCenter: center,
    radius: radius,
    startAngle: startAngle,
    endAngle: startAngle + 2 * .pi * awayRatio,
    clockwise: true
)

这里的实现思路是:

  • 两条路径都从同一个起点开始
  • 主队逆时针绘制,客队顺时针绘制
  • 各自的弧长由对应的比例决定

这种方式可以让两条圆弧自然地从起点向两侧展开,视觉上也比较直观。

最后将生成的路径赋值给对应的 CAShapeLayer:

Swift 复制代码
homeLayer.path = homePath.cgPath
awayLayer.path = awayPath.cgPath

2.4 默认状态的处理

在实际业务中,可能会遇到数据尚未返回的情况,但设计上通常仍然希望有一个占位展示。

这里提供了一个 renderDefault 方法,用来绘制一个 50% / 50% 的默认状态:

Swift 复制代码
func renderDefault() {
    let center = CGPoint(x: bounds.midX, y: bounds.midY)
    let radius = min(bounds.width, bounds.height) / 2 - 3
    let startAngle = -CGFloat.pi / 2
    
    let epsilon = CGFloat.pi / 1800 // ~0.1°
    let halfAngle = CGFloat.pi - epsilon
...
}

通过一个极小的角度偏移,避免两条路径在边界处完全重叠:

Swift 复制代码
let homePath = UIBezierPath(
    arcCenter: center,
    radius: radius,
    startAngle: startAngle,
    endAngle: startAngle - halfAngle,
    clockwise: false
)

let awayPath = UIBezierPath(
    arcCenter: center,
    radius: radius,
    startAngle: startAngle,
    endAngle: startAngle + halfAngle,
    clockwise: true
)

homeLayer.path = homePath.cgPath
awayLayer.path = awayPath.cgPath

这样在没有实际数据时,也能保持 UI 的完整性。

三. 条形占比视图的实现

射正球门、射偏球门等数据使用的是横向条形的占比视图。

这一部分的实现方式和环形视图完全不同,并没有使用绘制相关的 API,而是直接通过 UIView + Auto Layout 来完成。

3.1 视图结构

条形占比视图同样被封装成一个独立的 UIView:

Swift 复制代码
class ZMFootBallMatchStatusShotsView: UIView {
....
}

整体结构可以简单理解为:

  • 左侧:主队数值
  • 中间:条形图容器
  • 右侧:客队数值
  • 容器内部:左右各一个子 View,表示双方的占比

3.2 基础视图搭建

在 setupView 中创建并配置各个子视图:

Swift 复制代码
private func setupView() {
    self.addSubview(titleLabel)
    self.addSubview(homeLabel)
    self.addSubview(barContainerView)
    self.addSubview(awayLabel)
    
    barContainerView.addSubview(homeView)
    barContainerView.addSubview(awayView)
}

所有元素都是普通的 UIView 和 UILabel,没有任何额外的绘制逻辑。

3.3 布局与初始占位

使用 Auto Layout(这里通过 SnapKit)完成布局:

Swift 复制代码
homeView.snp.makeConstraints { make in
    make.leading.top.bottom.equalToSuperview()
    make.width.equalToSuperview().multipliedBy(0.5)
}

awayView.snp.makeConstraints { make in
    make.trailing.top.bottom.equalToSuperview()
    make.width.equalToSuperview().multipliedBy(0.5)
}

在初始状态下,主队和客队各占一半宽度,作为占位展示。

3.4 根据数据更新比例

当有实际数据时,通过重新计算比例并更新约束来驱动 UI,根据比例重新设置宽度约束:

Swift 复制代码
    /// 渲染数据
    func render(home: Int, away: Int) {
        let total = home + away
        guard total > 0 else { return }

        homeLabel.text = "\(home)"
        awayLabel.text = "\(away)"

        let homeRatio = CGFloat(home) / CGFloat(total)
        let awayRatio = CGFloat(away) / CGFloat(total)

        homeView.snp.remakeConstraints { make in
            make.leading.top.bottom.equalToSuperview()
            make.width.equalToSuperview().multipliedBy(homeRatio)
        }

        awayView.snp.remakeConstraints { make in
            make.trailing.top.bottom.equalToSuperview()
            make.width.equalToSuperview().multipliedBy(awayRatio)
        }
    }

这样就可以通过约束本身来表达"占比",不需要关心具体的像素值。

四. 结语

在这个足球比赛的数据面板中,环形占比视图和条形占比视图承担了不同的展示职责,因此在实现方式上也采用了完全不同的方案:

  • 环形占比视图

使用 CAShapeLayer + UIBezierPath,通过路径和角度来表达比例关系。

  • 条形占比视图

使用 UIView + Auto Layout,通过约束的比例关系来完成展示。

两种实现方式的目标只有一个:

  • 在不引入复杂逻辑的前提下,尽可能直接地还原设计稿,并保持代码结构清晰、易维护。
  • 在实际开发中,只要能准确表达 UI 意图,就是合适的实现方式。
相关推荐
CodeCraft Studio2 个月前
Excel处理控件Aspose.Cells教程:使用C#在Excel中创建环形图
java·c#·excel·aspose·环形图·excel环形图·图表创建
招风的黑耳1 年前
Axure设计之动态条形图教程(中继器)
axure·条形图·中继器·图表
胖虎12 年前
十.核心动画 - 显式动画(动画组,过渡动画,取消动画)
ios·核心动画·core animation
胖虎12 年前
五.核心动画 - 图层的变换(平移,缩放,旋转,3D变化)
3d·核心动画·core animation
数据科学知识库2 年前
数据可视化---饼图、环形图、雷达图
数据分析·matplotlib·数据可视化·seaborn·饼状图·环形图·雷达图
微小冷3 年前
python绘制3D条形图
python·matplotlib·数据可视化·3d条形图·条形图