
一. 引言
在足球比赛的详情页中,通常都会有一个双方数据面板,用来展示主队和客队在比赛过程中的对比情况。
这个页面中包含了多种类型的数据,比如进攻次数、危险进攻、控球率等。这类数据更关注的是双方的占比关系,因此在 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 意图,就是合适的实现方式。